--- a/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
+++ b/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
@@ -18,16 +18,17 @@ import org.mozilla.gecko.GeckoProfileDir
import org.mozilla.gecko.Tabs.TabEvents;
import org.mozilla.gecko.animation.PropertyAnimator;
import org.mozilla.gecko.animation.ViewHelper;
import org.mozilla.gecko.db.BrowserContract;
import org.mozilla.gecko.db.BrowserDB;
import org.mozilla.gecko.db.SuggestedSites;
import org.mozilla.gecko.distribution.Distribution;
import org.mozilla.gecko.dlc.DownloadContentService;
+import org.mozilla.gecko.dlc.catalog.DownloadContent;
import org.mozilla.gecko.favicons.Favicons;
import org.mozilla.gecko.favicons.OnFaviconLoadedListener;
import org.mozilla.gecko.favicons.decoders.IconDirectoryEntry;
import org.mozilla.gecko.feeds.FeedService;
import org.mozilla.gecko.feeds.action.CheckForUpdatesAction;
import org.mozilla.gecko.firstrun.FirstrunAnimationContainer;
import org.mozilla.gecko.gfx.DynamicToolbarAnimator;
import org.mozilla.gecko.gfx.DynamicToolbarAnimator.PinReason;
@@ -1938,16 +1939,19 @@ public class BrowserApp extends GeckoApp
@Override
public void run() {
GeckoPreferences.broadcastStumblerPref(BrowserApp.this);
}
}, oneSecondInMillis);
}
if (AppConstants.MOZ_ANDROID_DOWNLOAD_CONTENT_SERVICE) {
+ // TODO: Better scheduling of sync action (Bug 1257492)
+ DownloadContentService.startSync(this);
+
DownloadContentService.startVerification(this);
}
FeedService.setup(this);
super.handleMessage(event, message);
} else if (event.equals("Gecko:Ready")) {
// Handle this message in GeckoApp, but also enable the Settings
--- a/mobile/android/base/java/org/mozilla/gecko/dlc/BaseAction.java
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/BaseAction.java
@@ -4,27 +4,32 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.gecko.dlc;
import android.content.Context;
import android.support.annotation.IntDef;
import android.util.Log;
+import org.mozilla.gecko.AppConstants;
import org.mozilla.gecko.background.nativecode.NativeCrypto;
import org.mozilla.gecko.dlc.catalog.DownloadContent;
import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.util.HardwareUtils;
import org.mozilla.gecko.util.IOUtils;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.MalformedURLException;
+import java.net.URL;
public abstract class BaseAction {
private static final String LOGTAG = "GeckoDLCBaseAction";
/**
* Exception indicating a recoverable error has happened. Download of the content will be retried later.
*/
/* package-private */ static class RecoverableDownloadContentException extends Exception {
@@ -132,9 +137,27 @@ public abstract class BaseAction {
return true;
} catch (IOException e) {
// Recoverable: Just I/O discontinuation
throw new RecoverableDownloadContentException(RecoverableDownloadContentException.DISK_IO, e);
} finally {
IOUtils.safeStreamClose(inputStream);
}
}
+
+ protected HttpURLConnection buildHttpURLConnection(String url)
+ throws UnrecoverableDownloadContentException, IOException {
+ // TODO: Implement proxy support (Bug 1209496)
+ try {
+ System.setProperty("http.keepAlive", "true");
+
+ HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
+ connection.setRequestProperty("User-Agent", HardwareUtils.isTablet() ?
+ AppConstants.USER_AGENT_FENNEC_TABLET :
+ AppConstants.USER_AGENT_FENNEC_MOBILE);
+ connection.setRequestMethod("GET");
+ connection.setInstanceFollowRedirects(true);
+ return connection;
+ } catch (MalformedURLException e) {
+ throw new UnrecoverableDownloadContentException(e);
+ }
+ }
}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/CleanupAction.java
@@ -0,0 +1,49 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.dlc;
+
+import android.content.Context;
+
+import org.mozilla.gecko.dlc.catalog.DownloadContent;
+import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
+
+import java.io.File;
+
+/**
+ * CleanupAction: Remove content that is no longer needed.
+ */
+public class CleanupAction extends BaseAction {
+ @Override
+ public void perform(Context context, DownloadContentCatalog catalog) {
+ for (DownloadContent content : catalog.getContentToDelete()) {
+ if (!content.isAssetArchive()) {
+ continue; // We do not know how to clean up this content. But this means we didn't
+ // download it anyways.
+ }
+
+ try {
+ File file = getDestinationFile(context, content);
+
+ if (!file.exists()) {
+ // File does not exist. As good as deleting.
+ catalog.remove(content);
+ return;
+ }
+
+ if (file.delete()) {
+ // File has been deleted. Now remove it from the catalog.
+ catalog.remove(content);
+ }
+ } catch (UnrecoverableDownloadContentException e) {
+ // We can't recover. Pretend the content is removed. It probably never existed in
+ // the first place.
+ catalog.remove(content);
+ } catch (RecoverableDownloadContentException e) {
+ // Try again next time.
+ }
+ }
+ }
+}
--- a/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadAction.java
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadAction.java
@@ -36,19 +36,16 @@ import java.util.zip.GZIPInputStream;
*/
public class DownloadAction extends BaseAction {
private static final String LOGTAG = "DLCDownloadAction";
private static final String CACHE_DIRECTORY = "downloadContent";
private static final String CDN_BASE_URL = "https://mobile.cdn.mozilla.net/";
- private static final int STATUS_OK = 200;
- private static final int STATUS_PARTIAL_CONTENT = 206;
-
public interface Callback {
void onContentDownloaded(DownloadContent content);
}
private Callback callback;
public DownloadAction(Callback callback) {
this.callback = callback;
@@ -154,17 +151,17 @@ public class DownloadAction extends Base
connection = buildHttpURLConnection(source);
final long offset = temporaryFile.exists() ? temporaryFile.length() : 0;
if (offset > 0) {
connection.setRequestProperty("Range", "bytes=" + offset + "-");
}
final int status = connection.getResponseCode();
- if (status != STATUS_OK && status != STATUS_PARTIAL_CONTENT) {
+ if (status != HttpURLConnection.HTTP_OK && status != HttpURLConnection.HTTP_PARTIAL) {
// We are trying to be smart and only retry if this is an error that might resolve in the future.
// TODO: This is guesstimating at best. We want to implement failure counters (Bug 1215106).
if (status >= 500) {
// Recoverable: Server errors 5xx
throw new RecoverableDownloadContentException(RecoverableDownloadContentException.SERVER,
"(Recoverable) Download failed. Status code: " + status);
} else if (status >= 400) {
// Unrecoverable: Client errors 4xx - Unlikely that this version of the client will ever succeed.
@@ -174,17 +171,17 @@ public class DownloadAction extends Base
// Informational 1xx: They have no meaning to us.
// Successful 2xx: We don't know how to handle anything but 200.
// Redirection 3xx: HttpClient should have followed redirects if possible. We should not see those errors here.
throw new UnrecoverableDownloadContentException("(Unrecoverable) Download failed. Status code: " + status);
}
}
inputStream = new BufferedInputStream(connection.getInputStream());
- outputStream = openFile(temporaryFile, status == STATUS_PARTIAL_CONTENT);
+ outputStream = openFile(temporaryFile, status == HttpURLConnection.HTTP_PARTIAL);
IOUtils.copy(inputStream, outputStream);
inputStream.close();
outputStream.close();
} catch (IOException e) {
// Recoverable: Just I/O discontinuation
throw new RecoverableDownloadContentException(RecoverableDownloadContentException.NETWORK, e);
@@ -250,34 +247,19 @@ public class DownloadAction extends Base
return networkInfo != null && networkInfo.isConnected();
}
protected boolean isActiveNetworkMetered(Context context) {
return ConnectivityManagerCompat.isActiveNetworkMetered(
(ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE));
}
- protected HttpURLConnection buildHttpURLConnection(String url)
- throws UnrecoverableDownloadContentException, IOException {
- // TODO: Implement proxy support (Bug 1209496)
- try {
- System.setProperty("http.keepAlive", "true");
+ protected String createDownloadURL(DownloadContent content) {
+ final String location = content.getLocation();
- HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
- connection.setRequestProperty("User-Agent", HardwareUtils.isTablet() ?
- AppConstants.USER_AGENT_FENNEC_TABLET :
- AppConstants.USER_AGENT_FENNEC_MOBILE);
- connection.setRequestMethod("GET");
- return connection;
- } catch (MalformedURLException e) {
- throw new UnrecoverableDownloadContentException(e);
- }
- }
-
- protected String createDownloadURL(DownloadContent content) {
return CDN_BASE_URL + content.getLocation();
}
protected File createTemporaryFile(Context context, DownloadContent content)
throws RecoverableDownloadContentException {
File cacheDirectory = new File(context.getCacheDir(), CACHE_DIRECTORY);
if (!cacheDirectory.exists() && !cacheDirectory.mkdirs()) {
--- a/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadContentService.java
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadContentService.java
@@ -19,20 +19,41 @@ import android.content.Intent;
import android.util.Log;
/**
* Service to handle downloadable content that did not ship with the APK.
*/
public class DownloadContentService extends IntentService {
private static final String LOGTAG = "GeckoDLCService";
+ /**
+ * Study: Scan the catalog for "new" content available for download.
+ */
private static final String ACTION_STUDY_CATALOG = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.STUDY";
+
+ /**
+ * Verify: Validate downloaded content. Does it still exist and does it have the correct checksum?
+ */
private static final String ACTION_VERIFY_CONTENT = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.VERIFY";
+
+ /**
+ * Download content that has been scheduled during "study" or "verify".
+ */
private static final String ACTION_DOWNLOAD_CONTENT = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.DOWNLOAD";
+ /**
+ * Sync: Synchronize catalog from a Kinto instance.
+ */
+ private static final String ACTION_SYNCHRONIZE_CATALOG = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.SYNC";
+
+ /**
+ * CleanupAction: Remove content that is no longer needed (e.g. Removed from the catalog after a sync).
+ */
+ private static final String ACTION_CLEANUP_FILES = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.CLEANUP";
+
public static void startStudy(Context context) {
Intent intent = new Intent(ACTION_STUDY_CATALOG);
intent.setComponent(new ComponentName(context, DownloadContentService.class));
context.startService(intent);
}
public static void startVerification(Context context) {
Intent intent = new Intent(ACTION_VERIFY_CONTENT);
@@ -41,16 +62,28 @@ public class DownloadContentService exte
}
public static void startDownloads(Context context) {
Intent intent = new Intent(ACTION_DOWNLOAD_CONTENT);
intent.setComponent(new ComponentName(context, DownloadContentService.class));
context.startService(intent);
}
+ public static void startSync(Context context) {
+ Intent intent = new Intent(ACTION_SYNCHRONIZE_CATALOG);
+ intent.setComponent(new ComponentName(context, DownloadContentService.class));
+ context.startService(intent);
+ }
+
+ public static void startCleanup(Context context) {
+ Intent intent = new Intent(ACTION_CLEANUP_FILES);
+ intent.setComponent(new ComponentName(context, DownloadContentService.class));
+ context.startService(intent);
+ }
+
private DownloadContentCatalog catalog;
public DownloadContentService() {
super(LOGTAG);
}
@Override
public void onCreate() {
@@ -92,16 +125,20 @@ public class DownloadContentService exte
}
});
break;
case ACTION_VERIFY_CONTENT:
action = new VerifyAction();
break;
+ case ACTION_SYNCHRONIZE_CATALOG:
+ action = new SyncAction();
+ break;
+
default:
Log.e(LOGTAG, "Unknown action: " + intent.getAction());
return;
}
action.perform(this, catalog);
catalog.persistChanges();
}
--- a/mobile/android/base/java/org/mozilla/gecko/dlc/StudyAction.java
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/StudyAction.java
@@ -1,41 +1,85 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.gecko.dlc;
import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.Build;
+import android.text.TextUtils;
import android.util.Log;
import org.mozilla.gecko.dlc.catalog.DownloadContent;
import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
/**
* Study: Scan the catalog for "new" content available for download.
*/
public class StudyAction extends BaseAction {
private static final String LOGTAG = "DLCStudyAction";
public void perform(Context context, DownloadContentCatalog catalog) {
Log.d(LOGTAG, "Studying catalog..");
- for (DownloadContent content : catalog.getContentWithoutState()) {
+ for (DownloadContent content : catalog.getContentToStudy()) {
+ if (!isMatching(context, content)) {
+ // This content is not for this particular version of the application or system
+ continue;
+ }
+
if (content.isAssetArchive() && content.isFont()) {
catalog.scheduleDownload(content);
Log.d(LOGTAG, "Scheduled download: " + content);
}
}
if (catalog.hasScheduledDownloads()) {
startDownloads(context);
}
Log.v(LOGTAG, "Done");
}
+ protected boolean isMatching(Context context, DownloadContent content) {
+ final String androidApiPattern = content.getAndroidApiPattern();
+ if (!TextUtils.isEmpty(androidApiPattern)) {
+ final String apiVersion = String.valueOf(Build.VERSION.SDK_INT);
+ if (apiVersion.matches(androidApiPattern)) {
+ Log.d(LOGTAG, String.format("Android API (%s) does not match pattern: %s", apiVersion, androidApiPattern));
+ return false;
+ }
+ }
+
+ final String appIdPattern = content.getAppIdPattern();
+ if (!TextUtils.isEmpty(appIdPattern)) {
+ final String appId = context.getPackageName();
+ if (!appId.matches(appIdPattern)) {
+ Log.d(LOGTAG, String.format("App ID (%s) does not match pattern: %s", appId, appIdPattern));
+ return false;
+ }
+ }
+
+ final String appVersionPattern = content.getAppVersionPattern();
+ if (!TextUtils.isEmpty(appVersionPattern)) {
+ try {
+ final String appVersion = context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionName;
+ if (!appVersion.matches(appVersionPattern)) {
+ Log.d(LOGTAG, String.format("App version (%s) does not match pattern: %s", appVersion, appVersionPattern));
+ return false;
+ }
+ } catch (PackageManager.NameNotFoundException e) {
+ throw new AssertionError("Should not happen: Can't get package info of own package");
+ }
+ }
+
+ // There are no patterns or all patterns have matched.
+ return true;
+ }
+
protected void startDownloads(Context context) {
DownloadContentService.startDownloads(context);
}
}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/SyncAction.java
@@ -0,0 +1,254 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.dlc;
+
+import android.content.Context;
+import android.net.Uri;
+import android.util.Log;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.dlc.catalog.DownloadContent;
+import org.mozilla.gecko.dlc.catalog.DownloadContentBuilder;
+import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
+import org.mozilla.gecko.util.Experiments;
+import org.mozilla.gecko.util.IOUtils;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+
+/**
+ * Sync: Synchronize catalog from a Kinto instance.
+ */
+public class SyncAction extends BaseAction {
+ private static final String LOGTAG = "DLCSyncAction";
+
+ private static final String KINTO_KEY_ID = "id";
+ private static final String KINTO_KEY_DELETED = "deleted";
+ private static final String KINTO_KEY_DATA = "data";
+
+ private static final String KINTO_PARAMETER_SINCE = "_since";
+ private static final String KINTO_PARAMETER_FIELDS = "_fields";
+ private static final String KINTO_PARAMETER_SORT = "_sort";
+
+ /**
+ * Kinto endpoint with online version of downloadable content catalog
+ *
+ * Dev instance:
+ * https://kinto-ota.dev.mozaws.net/v1/buckets/dlc/collections/catalog/records
+ */
+ private static final String CATALOG_ENDPOINT = "https://firefox.settings.services.mozilla.com/v1/buckets/fennec-dlc/collections/catalog/records";
+
+ @Override
+ public void perform(Context context, DownloadContentCatalog catalog) {
+ Log.d(LOGTAG, "Synchronizing catalog.");
+
+ if (!isSyncEnabledForClient(context)) {
+ Log.d(LOGTAG, "Sync is not enabled for client. Skipping.");
+ return;
+ }
+
+ boolean cleanupRequired = false;
+ boolean studyRequired = false;
+
+ try {
+ long lastModified = catalog.getLastModified();
+
+ // TODO: Consider using ETag here (Bug 1257459)
+ JSONArray rawCatalog = fetchRawCatalog(lastModified);
+
+ Log.d(LOGTAG, "Server returned " + rawCatalog.length() + " records (since " + lastModified + ")");
+
+ for (int i = 0; i < rawCatalog.length(); i++) {
+ JSONObject object = rawCatalog.getJSONObject(i);
+ String id = object.getString(KINTO_KEY_ID);
+
+ final boolean isDeleted = object.optBoolean(KINTO_KEY_DELETED, false);
+
+ DownloadContent existingContent = catalog.getContentById(id);
+
+ if (isDeleted) {
+ cleanupRequired |= deleteContent(catalog, id);
+ } else if (existingContent != null) {
+ studyRequired |= updateContent(catalog, object, existingContent);
+ } else {
+ studyRequired |= createContent(catalog, object);
+ }
+ }
+ } catch (UnrecoverableDownloadContentException e) {
+ Log.e(LOGTAG, "UnrecoverableDownloadContentException", e);
+ } catch (RecoverableDownloadContentException e) {
+ Log.e(LOGTAG, "RecoverableDownloadContentException");
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "JSONException", e);
+ }
+
+ if (studyRequired) {
+ startStudyAction(context);
+ }
+
+ if (cleanupRequired) {
+ startCleanupAction(context);
+ }
+
+ Log.v(LOGTAG, "Done");
+ }
+
+ protected void startStudyAction(Context context) {
+ DownloadContentService.startStudy(context);
+ }
+
+ protected void startCleanupAction(Context context) {
+ DownloadContentService.startCleanup(context);
+ }
+
+ protected JSONArray fetchRawCatalog(long lastModified)
+ throws RecoverableDownloadContentException, UnrecoverableDownloadContentException {
+ HttpURLConnection connection = null;
+
+ try {
+ Uri.Builder builder = Uri.parse(CATALOG_ENDPOINT).buildUpon();
+
+ if (lastModified > 0) {
+ builder.appendQueryParameter(KINTO_PARAMETER_SINCE, String.valueOf(lastModified));
+ }
+ // Only select the fields we are actually going to read.
+ builder.appendQueryParameter(KINTO_PARAMETER_FIELDS,
+ "attachment.location,original.filename,original.hash,attachment.hash,type,kind,original.size,match");
+
+ // We want to process items in the order they have been modified. This is to ensure that
+ // our last_modified values are correct if we processing is interrupted and not all items
+ // have been processed.
+ builder.appendQueryParameter(KINTO_PARAMETER_SORT, "last_modified");
+
+ connection = buildHttpURLConnection(builder.build().toString());
+
+ // TODO: Read 'Alert' header and EOL message if existing (Bug 1249248)
+
+ // TODO: Read and use 'Backoff' header if available (Bug 1249251)
+
+ // TODO: Add support for Next-Page header (Bug 1257495)
+
+ final int responseCode = connection.getResponseCode();
+
+ if (responseCode != HttpURLConnection.HTTP_OK) {
+ if (responseCode >= 500) {
+ // A Retry-After header will be added to error responses (>=500), telling the
+ // client how many seconds it should wait before trying again.
+
+ // TODO: Read and obey value in "Retry-After" header (Bug 1249249)
+ throw new RecoverableDownloadContentException(RecoverableDownloadContentException.SERVER, "Server error (" + responseCode + ")");
+ } else if (responseCode == 410) {
+ // A 410 Gone error response can be returned if the client version is too old,
+ // or the service had been replaced with a new and better service using a new
+ // protocol version.
+
+ // TODO: The server is gone. Stop synchronizing the catalog from this server (Bug 1249248).
+ throw new UnrecoverableDownloadContentException("Server is gone (410)");
+ } else if (responseCode >= 400) {
+ // If the HTTP status is >=400 the response contains a JSON response.
+ logErrorResponse(connection);
+
+ // Unrecoverable: Client errors 4xx - Unlikely that this version of the client will ever succeed.
+ throw new UnrecoverableDownloadContentException("(Unrecoverable) Catalog sync failed. Status code: " + responseCode);
+ } else if (responseCode < 200) {
+ // If the HTTP status is <200 the response contains a JSON response.
+ logErrorResponse(connection);
+
+ throw new RecoverableDownloadContentException(RecoverableDownloadContentException.SERVER, "Response code: " + responseCode);
+ } else {
+ // HttpsUrlConnection: -1 (No valid response code)
+ // Successful 2xx: We don't know how to handle anything but 200.
+ // Redirection 3xx: We should have followed redirects if possible. We should not see those errors here.
+
+ throw new UnrecoverableDownloadContentException("(Unrecoverable) Download failed. Response code: " + responseCode);
+ }
+ }
+
+ return fetchJSONResponse(connection).getJSONArray(KINTO_KEY_DATA);
+ } catch (JSONException | IOException e) {
+ throw new RecoverableDownloadContentException(RecoverableDownloadContentException.NETWORK, e);
+ } finally {
+ if (connection != null) {
+ connection.disconnect();
+ }
+ }
+ }
+
+ private JSONObject fetchJSONResponse(HttpURLConnection connection) throws IOException, JSONException {
+ InputStream inputStream = null;
+
+ try {
+ inputStream = new BufferedInputStream(connection.getInputStream());
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ IOUtils.copy(inputStream, outputStream);
+ return new JSONObject(outputStream.toString("UTF-8"));
+ } finally {
+ IOUtils.safeStreamClose(inputStream);
+ }
+ }
+
+ protected boolean updateContent(DownloadContentCatalog catalog, JSONObject object, DownloadContent existingContent)
+ throws JSONException {
+ DownloadContent content = existingContent.buildUpon()
+ .updateFromKinto(object)
+ .build();
+
+ if (existingContent.getLastModified() >= content.getLastModified()) {
+ Log.d(LOGTAG, "Item has not changed: " + content);
+ return false;
+ }
+
+ catalog.update(content);
+
+ return true;
+ }
+
+ protected boolean createContent(DownloadContentCatalog catalog, JSONObject object) throws JSONException {
+ DownloadContent content = new DownloadContentBuilder()
+ .updateFromKinto(object)
+ .build();
+
+ catalog.add(content);
+
+ return true;
+ }
+
+ protected boolean deleteContent(DownloadContentCatalog catalog, String id) {
+ DownloadContent content = catalog.getContentById(id);
+ if (content == null) {
+ return false;
+ }
+
+ catalog.markAsDeleted(content);
+
+ return true;
+ }
+
+ protected boolean isSyncEnabledForClient(Context context) {
+ // Sync action is behind a switchboard flag for staged rollout.
+ return Experiments.isInExperimentLocal(context, Experiments.DOWNLOAD_CONTENT_CATALOG_SYNC);
+ }
+
+ private void logErrorResponse(HttpURLConnection connection) {
+ try {
+ JSONObject error = fetchJSONResponse(connection);
+
+ Log.w(LOGTAG, "Server returned error response:");
+ Log.w(LOGTAG, "- Code: " + error.getInt("code"));
+ Log.w(LOGTAG, "- Errno: " + error.getInt("errno"));
+ Log.w(LOGTAG, "- Error: " + error.optString("error", "-"));
+ Log.w(LOGTAG, "- Message: " + error.optString("message", "-"));
+ Log.w(LOGTAG, "- Info: " + error.optString("info", "-"));
+ } catch (JSONException | IOException e) {
+ Log.w(LOGTAG, "Could not fetch error response", e);
+ }
+ }
+}
--- a/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContent.java
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContent.java
@@ -2,42 +2,29 @@
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.gecko.dlc.catalog;
import android.support.annotation.IntDef;
import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
import android.support.annotation.StringDef;
-import org.json.JSONException;
-import org.json.JSONObject;
-
public class DownloadContent {
- private static final String KEY_ID = "id";
- private static final String KEY_LOCATION = "location";
- private static final String KEY_FILENAME = "filename";
- private static final String KEY_CHECKSUM = "checksum";
- private static final String KEY_DOWNLOAD_CHECKSUM = "download_checksum";
- private static final String KEY_LAST_MODIFIED = "last_modified";
- private static final String KEY_TYPE = "type";
- private static final String KEY_KIND = "kind";
- private static final String KEY_SIZE = "size";
- private static final String KEY_STATE = "state";
- private static final String KEY_FAILURES = "failures";
- private static final String KEY_LAST_FAILURE_TYPE = "last_failure_type";
-
- @IntDef({STATE_NONE, STATE_SCHEDULED, STATE_DOWNLOADED, STATE_FAILED, STATE_IGNORED})
+ @IntDef({STATE_NONE, STATE_SCHEDULED, STATE_DOWNLOADED, STATE_FAILED, STATE_IGNORED, STATE_UPDATED, STATE_DELETED})
public @interface State {}
public static final int STATE_NONE = 0;
public static final int STATE_SCHEDULED = 1;
public static final int STATE_DOWNLOADED = 2;
public static final int STATE_FAILED = 3; // Permanently failed for this version of the content
public static final int STATE_IGNORED = 4;
+ public static final int STATE_UPDATED = 5;
+ public static final int STATE_DELETED = 6;
@StringDef({TYPE_ASSET_ARCHIVE})
public @interface Type {}
public static final String TYPE_ASSET_ARCHIVE = "asset-archive";
@StringDef({KIND_FONT})
public @interface Kind {}
public static final String KIND_FONT = "font";
@@ -46,48 +33,67 @@ public class DownloadContent {
private final String location;
private final String filename;
private final String checksum;
private final String downloadChecksum;
private final long lastModified;
private final String type;
private final String kind;
private final long size;
+ private final String appVersionPattern;
+ private final String androidApiPattern;
+ private final String appIdPattern;
private int state;
private int failures;
private int lastFailureType;
- private DownloadContent(@NonNull String id, @NonNull String location, @NonNull String filename,
+ /* package-private */ DownloadContent(@NonNull String id, @NonNull String location, @NonNull String filename,
@NonNull String checksum, @NonNull String downloadChecksum, @NonNull long lastModified,
- @NonNull String type, @NonNull String kind, long size) {
+ @NonNull String type, @NonNull String kind, long size, int failures, int lastFailureType,
+ @Nullable String appVersionPattern, @Nullable String androidApiPattern, @Nullable String appIdPattern) {
this.id = id;
this.location = location;
this.filename = filename;
this.checksum = checksum;
this.downloadChecksum = downloadChecksum;
this.lastModified = lastModified;
this.type = type;
this.kind = kind;
this.size = size;
this.state = STATE_NONE;
+ this.failures = failures;
+ this.lastFailureType = lastFailureType;
+ this.appVersionPattern = appVersionPattern;
+ this.androidApiPattern = androidApiPattern;
+ this.appIdPattern = appIdPattern;
}
public String getId() {
return id;
}
/* package-private */ void setState(@State int state) {
this.state = state;
}
@State
public int getState() {
return state;
}
+ public boolean isStateIn(@State int... states) {
+ for (int state : states) {
+ if (this.state == state) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
@Kind
public String getKind() {
return kind;
}
@Type
public String getType() {
return type;
@@ -124,145 +130,47 @@ public class DownloadContent {
public boolean isAssetArchive() {
return TYPE_ASSET_ARCHIVE.equals(type);
}
/* package-private */ int getFailures() {
return failures;
}
+ /* package-private */ int getLastFailureType() {
+ return lastFailureType;
+ }
+
/* package-private */ void rememberFailure(int failureType) {
if (lastFailureType != failureType) {
lastFailureType = failureType;
failures = 1;
} else {
failures++;
}
}
/* package-private */ void resetFailures() {
failures = 0;
lastFailureType = 0;
}
- public static DownloadContent fromJSON(JSONObject object) throws JSONException {
- return new Builder()
- .setId(object.getString(KEY_ID))
- .setLocation(object.getString(KEY_LOCATION))
- .setFilename(object.getString(KEY_FILENAME))
- .setChecksum(object.getString(KEY_CHECKSUM))
- .setDownloadChecksum(object.getString(KEY_DOWNLOAD_CHECKSUM))
- .setLastModified(object.getLong(KEY_LAST_MODIFIED))
- .setType(object.getString(KEY_TYPE))
- .setKind(object.getString(KEY_KIND))
- .setSize(object.getLong(KEY_SIZE))
- .setState(object.getInt(KEY_STATE))
- .setFailures(object.optInt(KEY_FAILURES), object.optInt(KEY_LAST_FAILURE_TYPE))
- .build();
+ public String getAppVersionPattern() {
+ return appVersionPattern;
+ }
+
+ public String getAndroidApiPattern() {
+ return androidApiPattern;
}
- public JSONObject toJSON() throws JSONException {
- JSONObject object = new JSONObject();
- object.put(KEY_ID, id);
- object.put(KEY_LOCATION, location);
- object.put(KEY_FILENAME, filename);
- object.put(KEY_CHECKSUM, checksum);
- object.put(KEY_DOWNLOAD_CHECKSUM, downloadChecksum);
- object.put(KEY_LAST_MODIFIED, lastModified);
- object.put(KEY_TYPE, type);
- object.put(KEY_KIND, kind);
- object.put(KEY_SIZE, size);
- object.put(KEY_STATE, state);
+ public String getAppIdPattern() {
+ return appIdPattern;
+ }
- if (failures > 0) {
- object.put(KEY_FAILURES, failures);
- object.put(KEY_LAST_FAILURE_TYPE, lastFailureType);
- }
+ public DownloadContentBuilder buildUpon() {
+ return DownloadContentBuilder.buildUpon(this);
+ }
- return object;
- }
public String toString() {
return String.format("[%s,%s] %s (%d bytes) %s", getType(), getKind(), getId(), getSize(), getChecksum());
}
-
- public static class Builder {
- private String id;
- private String location;
- private String filename;
- private String checksum;
- private String downloadChecksum;
- private long lastModified;
- private String type;
- private String kind;
- private long size;
- private int state;
- private int failures;
- private int lastFailureType;
-
- public DownloadContent build() {
- DownloadContent content = new DownloadContent(id, location, filename, checksum, downloadChecksum,
- lastModified, type, kind, size);
- content.setState(state);
- content.failures = failures;
- content.lastFailureType = lastFailureType;
-
- return content;
- }
-
- public Builder setId(String id) {
- this.id = id;
- return this;
- }
-
- public Builder setLocation(String location) {
- this.location = location;
- return this;
- }
-
- public Builder setFilename(String filename) {
- this.filename = filename;
- return this;
- }
-
- public Builder setChecksum(String checksum) {
- this.checksum = checksum;
- return this;
- }
-
- public Builder setDownloadChecksum(String downloadChecksum) {
- this.downloadChecksum = downloadChecksum;
- return this;
- }
-
- public Builder setLastModified(long lastModified) {
- this.lastModified = lastModified;
- return this;
- }
-
- public Builder setType(String type) {
- this.type = type;
- return this;
- }
-
- public Builder setKind(String kind) {
- this.kind = kind;
- return this;
- }
-
- public Builder setSize(long size) {
- this.size = size;
- return this;
- }
-
- public Builder setState(int state) {
- this.state = state;
- return this;
- }
-
- /* package-private */ Builder setFailures(int failures, int lastFailureType) {
- this.failures = failures;
- this.lastFailureType = lastFailureType;
-
- return this;
- }
- }
}
--- a/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentBootstrap.java
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentBootstrap.java
@@ -1,156 +1,161 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.gecko.dlc.catalog;
+import android.support.v4.util.ArrayMap;
+
import org.mozilla.gecko.AppConstants;
-import org.mozilla.gecko.dlc.catalog.DownloadContent;
import java.util.Arrays;
-import java.util.Collections;
import java.util.List;
/* package-private */ class DownloadContentBootstrap {
- public static List<DownloadContent> createInitialDownloadContentList() {
+ public static ArrayMap<String, DownloadContent> createInitialDownloadContentList() {
if (!AppConstants.MOZ_ANDROID_EXCLUDE_FONTS) {
// We are packaging fonts. There's nothing we want to download;
- return Collections.emptyList();
+ return new ArrayMap<>();
}
- return Arrays.asList(
- new DownloadContent.Builder()
+ List<DownloadContent> initialList = Arrays.asList(
+ new DownloadContentBuilder()
.setId("c40929cf-7f4c-fa72-3dc9-12cadf56905d")
.setLocation("fonts/ff7ecae7669a51d5fa6a5f8e703278ebda3a68f51bc49c4321bde4438020d639.gz")
.setFilename("CharisSILCompact-B.ttf")
.setChecksum("699d958b492eda0cc2823535f8567d0393090e3842f6df3c36dbe7239cb80b6d")
.setDownloadChecksum("ff7ecae7669a51d5fa6a5f8e703278ebda3a68f51bc49c4321bde4438020d639")
.setSize(1676072)
.setKind("font")
.setType("asset-archive")
.build(),
- new DownloadContent.Builder()
+ new DownloadContentBuilder()
.setId("6d265876-85ed-0917-fdc8-baf583ca2cba")
.setLocation("fonts/dfb6d583edd27d5e6d91d479e6c8a5706275662c940c65b70911493bb279904a.gz")
.setFilename("CharisSILCompact-BI.ttf")
.setChecksum("82465e747b4f41471dbfd942842b2ee810749217d44b55dbc43623b89f9c7d9b")
.setDownloadChecksum("dfb6d583edd27d5e6d91d479e6c8a5706275662c940c65b70911493bb279904a")
.setSize(1667812)
.setKind("font")
.setType("asset-archive")
.build(),
- new DownloadContent.Builder()
+ new DownloadContentBuilder()
.setId("8460dc6d-d129-fd1a-24b6-343dbf6531dd")
.setLocation("fonts/5a257ec3c5226e7be0be65e463f5b22eff108da853b9ff7bc47f1733b1ddacf2.gz")
.setFilename("CharisSILCompact-I.ttf")
.setChecksum("ab3ed6f2a4d4c2095b78227bd33155d7ccd05a879c107a291912640d4d64f767")
.setDownloadChecksum("5a257ec3c5226e7be0be65e463f5b22eff108da853b9ff7bc47f1733b1ddacf2")
.setSize(1693988)
.setKind("font")
.setType("asset-archive")
.build(),
- new DownloadContent.Builder()
+ new DownloadContentBuilder()
.setId("c906275c-3747-fe27-426f-6187526a6f06")
.setLocation("fonts/cab284228b8dfe8ef46c3f1af70b5b6f9e92878f05e741ecc611e5e750a4a3b3.gz")
.setFilename("CharisSILCompact-R.ttf")
.setChecksum("4ed509317f1bb441b185ea13bf1c9d19d1a0b396962efa3b5dc3190ad88f2067")
.setDownloadChecksum("cab284228b8dfe8ef46c3f1af70b5b6f9e92878f05e741ecc611e5e750a4a3b3")
.setSize(1727656)
.setKind("font")
.setType("asset-archive")
.build(),
- new DownloadContent.Builder()
+ new DownloadContentBuilder()
.setId("ff5deecc-6ecc-d816-bb51-65face460119")
.setLocation("fonts/d95168996dc932e6504cb5448fcb759e0ee6e66c5c8603293b046d28ab589cce.gz")
.setFilename("ClearSans-Bold.ttf")
.setChecksum("385d0a293c1714770e198f7c762ab32f7905a0ed9d2993f69d640bd7232b4b70")
.setDownloadChecksum("d95168996dc932e6504cb5448fcb759e0ee6e66c5c8603293b046d28ab589cce")
.setSize(140136)
.setKind("font")
.setType("asset-archive")
.build(),
- new DownloadContent.Builder()
+ new DownloadContentBuilder()
.setId("a173d1db-373b-ce42-1335-6b3285cfdebd")
.setLocation("fonts/f5e18f4acc4ceaeca9e081b1be79cd6034e0dc7ad683fa240195fd6c838452e0.gz")
.setFilename("ClearSans-BoldItalic.ttf")
.setChecksum("7bce66864e38eecd7c94b6657b9b38c35ebfacf8046bfb1247e08f07fe933198")
.setDownloadChecksum("f5e18f4acc4ceaeca9e081b1be79cd6034e0dc7ad683fa240195fd6c838452e0")
.setSize(156124)
.setKind("font")
.setType("asset-archive")
.build(),
- new DownloadContent.Builder()
+ new DownloadContentBuilder()
.setId("e65c66df-0088-940d-ca5c-207c22118c0e")
.setLocation("fonts/56d12114ac15d913d7d9876c698889cd25f26e14966a8bd7424aeb0f61ffaf87.gz")
.setFilename("ClearSans-Italic.ttf")
.setChecksum("87c13c5fbae832e4f85c3bd46fcbc175978234f39be5fe51c4937be4cbff3b68")
.setDownloadChecksum("56d12114ac15d913d7d9876c698889cd25f26e14966a8bd7424aeb0f61ffaf87")
.setSize(155672)
.setKind("font")
.setType("asset-archive")
.build(),
- new DownloadContent.Builder()
+ new DownloadContentBuilder()
.setId("25610abb-5dc8-fd75-40e7-990507f010c4")
.setLocation("fonts/1fc716662866b9c01e32dda3fc9c54ca3e57de8c6ac523f46305d8ae6c0a9cf4.gz")
.setFilename("ClearSans-Light.ttf")
.setChecksum("e4885f6188e7a8587f5621c077c6c1f5e8d3739dffc8f4d055c2ba87568c750a")
.setDownloadChecksum("1fc716662866b9c01e32dda3fc9c54ca3e57de8c6ac523f46305d8ae6c0a9cf4")
.setSize(145976)
.setKind("font")
.setType("asset-archive")
.build(),
- new DownloadContent.Builder()
+ new DownloadContentBuilder()
.setId("ffe40339-a096-2262-c3f8-54af75c81fe6")
.setLocation("fonts/a29184ec6621dbd3bc6ae1e30bba70c479d1001bca647ea4a205ecb64d5a00a0.gz")
.setFilename("ClearSans-Medium.ttf")
.setChecksum("5d0e0115f3a3ed4be3eda6d7eabb899bb9a361292802e763d53c72e00f629da1")
.setDownloadChecksum("a29184ec6621dbd3bc6ae1e30bba70c479d1001bca647ea4a205ecb64d5a00a0")
.setSize(148892)
.setKind("font")
.setType("asset-archive")
.build(),
- new DownloadContent.Builder()
+ new DownloadContentBuilder()
.setId("139a94be-ac69-0264-c9cc-8f2d071fd29d")
.setLocation("fonts/a381a3d4060e993af440a7b72fed29fa3a488536cc451d7c435d5fae1256318b.gz")
.setFilename("ClearSans-MediumItalic.ttf")
.setChecksum("937dda88b26469306258527d38e42c95e27e7ebb9f05bd1d7c5d706a3c9541d7")
.setDownloadChecksum("a381a3d4060e993af440a7b72fed29fa3a488536cc451d7c435d5fae1256318b")
.setSize(155228)
.setKind("font")
.setType("asset-archive")
.build(),
- new DownloadContent.Builder()
+ new DownloadContentBuilder()
.setId("b887012a-01e1-7c94-fdcb-ca44d5b974a2")
.setLocation("fonts/87dec7f0331e19b293fc510f2764b9bd1b94595ac279cf9414f8d03c5bf34dca.gz")
.setFilename("ClearSans-Regular.ttf")
.setChecksum("9b91bbdb95ffa6663da24fdaa8ee06060cd0a4d2dceaf1ffbdda00e04915ee5b")
.setDownloadChecksum("87dec7f0331e19b293fc510f2764b9bd1b94595ac279cf9414f8d03c5bf34dca")
.setSize(142572)
.setKind("font")
.setType("asset-archive")
.build(),
- new DownloadContent.Builder()
+ new DownloadContentBuilder()
.setId("c8703652-d317-0356-0bf8-95441a5b2c9b")
.setLocation("fonts/64300b48b2867e5642212690f0ff9ea3988f47790311c444a81d25213b4102aa.gz")
.setFilename("ClearSans-Thin.ttf")
.setChecksum("07b0db85a3ad99afeb803f0f35631436a7b4c67ac66d0c7f77d26a47357c592a")
.setDownloadChecksum("64300b48b2867e5642212690f0ff9ea3988f47790311c444a81d25213b4102aa")
.setSize(147004)
.setKind("font")
.setType("asset-archive")
- .build()
- );
+ .build());
+
+ ArrayMap<String, DownloadContent> content = new ArrayMap<>();
+ for (DownloadContent currentContent : initialList) {
+ content.put(currentContent.getId(), currentContent);
+ }
+ return content;
}
}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentBuilder.java
@@ -0,0 +1,239 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.dlc.catalog;
+
+import android.text.TextUtils;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+public class DownloadContentBuilder {
+ private static final String LOCAL_KEY_ID = "id";
+ private static final String LOCAL_KEY_LOCATION = "location";
+ private static final String LOCAL_KEY_FILENAME = "filename";
+ private static final String LOCAL_KEY_CHECKSUM = "checksum";
+ private static final String LOCAL_KEY_DOWNLOAD_CHECKSUM = "download_checksum";
+ private static final String LOCAL_KEY_LAST_MODIFIED = "last_modified";
+ private static final String LOCAL_KEY_TYPE = "type";
+ private static final String LOCAL_KEY_KIND = "kind";
+ private static final String LOCAL_KEY_SIZE = "size";
+ private static final String LOCAL_KEY_STATE = "state";
+ private static final String LOCAL_KEY_FAILURES = "failures";
+ private static final String LOCAL_KEY_LAST_FAILURE_TYPE = "last_failure_type";
+ private static final String LOCAL_KEY_PATTERN_APP_ID = "pattern_app_id";
+ private static final String LOCAL_KEY_PATTERN_ANDROID_API = "pattern_android_api";
+ private static final String LOCAL_KEY_PATTERN_APP_VERSION = "pattern_app_version";
+
+ private static final String KINTO_KEY_ID = "id";
+ private static final String KINTO_KEY_ATTACHMENT = "attachment";
+ private static final String KINTO_KEY_ORIGINAL = "original";
+ private static final String KINTO_KEY_LOCATION = "location";
+ private static final String KINTO_KEY_FILENAME = "filename";
+ private static final String KINTO_KEY_HASH = "hash";
+ private static final String KINTO_KEY_LAST_MODIFIED = "last_modified";
+ private static final String KINTO_KEY_TYPE = "type";
+ private static final String KINTO_KEY_KIND = "kind";
+ private static final String KINTO_KEY_SIZE = "size";
+ private static final String KINTO_KEY_MATCH = "match";
+ private static final String KINTO_KEY_APP_ID = "appId";
+ private static final String KINTO_KEY_ANDROID_API = "androidApi";
+ private static final String KINTO_KEY_APP_VERSION = "appVersion";
+
+ private String id;
+ private String location;
+ private String filename;
+ private String checksum;
+ private String downloadChecksum;
+ private long lastModified;
+ private String type;
+ private String kind;
+ private long size;
+ private int state;
+ private int failures;
+ private int lastFailureType;
+ private String appVersionPattern;
+ private String androidApiPattern;
+ private String appIdPattern;
+
+ public static DownloadContentBuilder buildUpon(DownloadContent content) {
+ DownloadContentBuilder builder = new DownloadContentBuilder();
+
+ builder.id = content.getId();
+ builder.location = content.getLocation();
+ builder.filename = content.getFilename();
+ builder.checksum = content.getChecksum();
+ builder.downloadChecksum = content.getDownloadChecksum();
+ builder.lastModified = content.getLastModified();
+ builder.type = content.getType();
+ builder.kind = content.getKind();
+ builder.size = content.getSize();
+ builder.state = content.getState();
+ builder.failures = content.getFailures();
+ builder.lastFailureType = content.getLastFailureType();
+
+ return builder;
+ }
+
+ public static DownloadContent fromJSON(JSONObject object) throws JSONException {
+ return new DownloadContentBuilder()
+ .setId(object.getString(LOCAL_KEY_ID))
+ .setLocation(object.getString(LOCAL_KEY_LOCATION))
+ .setFilename(object.getString(LOCAL_KEY_FILENAME))
+ .setChecksum(object.getString(LOCAL_KEY_CHECKSUM))
+ .setDownloadChecksum(object.getString(LOCAL_KEY_DOWNLOAD_CHECKSUM))
+ .setLastModified(object.getLong(LOCAL_KEY_LAST_MODIFIED))
+ .setType(object.getString(LOCAL_KEY_TYPE))
+ .setKind(object.getString(LOCAL_KEY_KIND))
+ .setSize(object.getLong(LOCAL_KEY_SIZE))
+ .setState(object.getInt(LOCAL_KEY_STATE))
+ .setFailures(object.optInt(LOCAL_KEY_FAILURES), object.optInt(LOCAL_KEY_LAST_FAILURE_TYPE))
+ .setAppVersionPattern(object.optString(LOCAL_KEY_PATTERN_APP_VERSION))
+ .setAppIdPattern(object.optString(LOCAL_KEY_PATTERN_APP_ID))
+ .setAndroidApiPattern(object.optString(LOCAL_KEY_PATTERN_ANDROID_API))
+ .build();
+ }
+
+ public static JSONObject toJSON(DownloadContent content) throws JSONException {
+ final JSONObject object = new JSONObject();
+ object.put(LOCAL_KEY_ID, content.getId());
+ object.put(LOCAL_KEY_LOCATION, content.getLocation());
+ object.put(LOCAL_KEY_FILENAME, content.getFilename());
+ object.put(LOCAL_KEY_CHECKSUM, content.getChecksum());
+ object.put(LOCAL_KEY_DOWNLOAD_CHECKSUM, content.getDownloadChecksum());
+ object.put(LOCAL_KEY_LAST_MODIFIED, content.getLastModified());
+ object.put(LOCAL_KEY_TYPE, content.getType());
+ object.put(LOCAL_KEY_KIND, content.getKind());
+ object.put(LOCAL_KEY_SIZE, content.getSize());
+ object.put(LOCAL_KEY_STATE, content.getState());
+ object.put(LOCAL_KEY_PATTERN_APP_VERSION, content.getAppVersionPattern());
+ object.put(LOCAL_KEY_PATTERN_APP_ID, content.getAppIdPattern());
+ object.put(LOCAL_KEY_PATTERN_ANDROID_API, content.getAndroidApiPattern());
+
+ final int failures = content.getFailures();
+ if (failures > 0) {
+ object.put(LOCAL_KEY_FAILURES, failures);
+ object.put(LOCAL_KEY_LAST_FAILURE_TYPE, content.getLastFailureType());
+ }
+
+ return object;
+ }
+
+ public DownloadContent build() {
+ DownloadContent content = new DownloadContent(id, location, filename, checksum,
+ downloadChecksum, lastModified, type, kind, size, failures, lastFailureType,
+ appVersionPattern, androidApiPattern, appIdPattern);
+ content.setState(state);
+
+ return content;
+ }
+
+ public DownloadContentBuilder setId(String id) {
+ this.id = id;
+ return this;
+ }
+
+ public DownloadContentBuilder setLocation(String location) {
+ this.location = location;
+ return this;
+ }
+
+ public DownloadContentBuilder setFilename(String filename) {
+ this.filename = filename;
+ return this;
+ }
+
+ public DownloadContentBuilder setChecksum(String checksum) {
+ this.checksum = checksum;
+ return this;
+ }
+
+ public DownloadContentBuilder setDownloadChecksum(String downloadChecksum) {
+ this.downloadChecksum = downloadChecksum;
+ return this;
+ }
+
+ public DownloadContentBuilder setLastModified(long lastModified) {
+ this.lastModified = lastModified;
+ return this;
+ }
+
+ public DownloadContentBuilder setType(String type) {
+ this.type = type;
+ return this;
+ }
+
+ public DownloadContentBuilder setKind(String kind) {
+ this.kind = kind;
+ return this;
+ }
+
+ public DownloadContentBuilder setSize(long size) {
+ this.size = size;
+ return this;
+ }
+
+ public DownloadContentBuilder setState(int state) {
+ this.state = state;
+ return this;
+ }
+
+ /* package-private */ DownloadContentBuilder setFailures(int failures, int lastFailureType) {
+ this.failures = failures;
+ this.lastFailureType = lastFailureType;
+
+ return this;
+ }
+
+ public DownloadContentBuilder setAppVersionPattern(String appVersionPattern) {
+ this.appVersionPattern = appVersionPattern;
+ return this;
+ }
+
+ public DownloadContentBuilder setAndroidApiPattern(String androidApiPattern) {
+ this.androidApiPattern = androidApiPattern;
+ return this;
+ }
+
+ public DownloadContentBuilder setAppIdPattern(String appIdPattern) {
+ this.appIdPattern = appIdPattern;
+ return this;
+ }
+
+ public DownloadContentBuilder updateFromKinto(JSONObject object) throws JSONException {
+ final String objectId = object.getString(KINTO_KEY_ID);
+
+ if (TextUtils.isEmpty(id)) {
+ // New object without an id yet
+ id = objectId;
+ } else if (!id.equals(objectId)) {
+ throw new JSONException(String.format("Record ids do not match: Expected=%s, Actual=%s", id, objectId));
+ }
+
+ setType(object.getString(KINTO_KEY_TYPE));
+ setKind(object.getString(KINTO_KEY_KIND));
+ setLastModified(object.getLong(KINTO_KEY_LAST_MODIFIED));
+
+ JSONObject original = object.getJSONObject(KINTO_KEY_ORIGINAL);
+
+ setFilename(original.getString(KINTO_KEY_FILENAME));
+ setChecksum(original.getString(KINTO_KEY_HASH));
+ setSize(original.getLong(KINTO_KEY_SIZE));
+
+ JSONObject attachment = object.getJSONObject(KINTO_KEY_ATTACHMENT);
+
+ setLocation(attachment.getString(KINTO_KEY_LOCATION));
+ setDownloadChecksum(attachment.getString(KINTO_KEY_HASH));
+
+ JSONObject match = object.optJSONObject(KINTO_KEY_MATCH);
+ if (match != null) {
+ setAndroidApiPattern(match.optString(KINTO_KEY_ANDROID_API));
+ setAppIdPattern(match.optString(KINTO_KEY_APP_ID));
+ setAppVersionPattern(match.optString(KINTO_KEY_APP_VERSION));
+ }
+
+ return this;
+ }
+}
--- a/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentCatalog.java
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentCatalog.java
@@ -1,112 +1,149 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.gecko.dlc.catalog;
import android.content.Context;
+import android.support.annotation.Nullable;
+import android.support.v4.util.ArrayMap;
import android.support.v4.util.AtomicFile;
import android.util.Log;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
-import java.util.Collections;
import java.util.List;
/**
* Catalog of downloadable content (DLC).
*
* Changing elements returned by the catalog should be guarded by the catalog instance to guarantee visibility when
* persisting changes.
*/
public class DownloadContentCatalog {
private static final String LOGTAG = "GeckoDLCCatalog";
private static final String FILE_NAME = "download_content_catalog";
private static final String JSON_KEY_CONTENT = "content";
+
private static final int MAX_FAILURES_UNTIL_PERMANENTLY_FAILED = 10;
- private final AtomicFile file; // Guarded by 'file'
- private List<DownloadContent> content; // Guarded by 'this'
- private boolean hasLoadedCatalog; // Guarded by 'this
- private boolean hasCatalogChanged; // Guarded by 'this'
+ private final AtomicFile file; // Guarded by 'file'
+
+ private ArrayMap<String, DownloadContent> content; // Guarded by 'this'
+ private boolean hasLoadedCatalog; // Guarded by 'this
+ private boolean hasCatalogChanged; // Guarded by 'this'
public DownloadContentCatalog(Context context) {
this(new AtomicFile(new File(context.getApplicationInfo().dataDir, FILE_NAME)));
startLoadFromDisk();
}
// For injecting mocked AtomicFile objects during test
protected DownloadContentCatalog(AtomicFile file) {
- this.content = Collections.emptyList();
+ this.content = new ArrayMap<>();
this.file = file;
}
- public synchronized List<DownloadContent> getContentWithoutState() {
+ public List<DownloadContent> getContentToStudy() {
+ return filterByState(DownloadContent.STATE_NONE, DownloadContent.STATE_UPDATED);
+ }
+
+ public List<DownloadContent> getContentToDelete() {
+ return filterByState(DownloadContent.STATE_DELETED);
+ }
+
+ public List<DownloadContent> getDownloadedContent() {
+ return filterByState(DownloadContent.STATE_DOWNLOADED);
+ }
+
+ public List<DownloadContent> getScheduledDownloads() {
+ return filterByState(DownloadContent.STATE_SCHEDULED);
+ }
+
+ private synchronized List<DownloadContent> filterByState(@DownloadContent.State int... filterStates) {
awaitLoadingCatalogLocked();
- List<DownloadContent> contentWithoutState = new ArrayList<>();
+ List<DownloadContent> filteredContent = new ArrayList<>();
- for (DownloadContent content : this.content) {
- if (DownloadContent.STATE_NONE == content.getState()) {
- contentWithoutState.add(content);
+ for (DownloadContent currentContent : content.values()) {
+ if (currentContent.isStateIn(filterStates)) {
+ filteredContent.add(currentContent);
}
}
- return contentWithoutState;
+ return filteredContent;
+ }
+
+ public boolean hasScheduledDownloads() {
+ return !filterByState(DownloadContent.STATE_SCHEDULED).isEmpty();
+ }
+
+ public synchronized void add(DownloadContent newContent) {
+ awaitLoadingCatalogLocked();
+
+ content.put(newContent.getId(), newContent);
+ hasCatalogChanged = true;
}
- public synchronized List<DownloadContent> getDownloadedContent() {
+ public synchronized void update(DownloadContent changedContent) {
+ awaitLoadingCatalogLocked();
+
+ if (!content.containsKey(changedContent.getId())) {
+ Log.w(LOGTAG, "Did not find content with matching id (" + changedContent.getId() + ") to update");
+ return;
+ }
+
+ changedContent.setState(DownloadContent.STATE_UPDATED);
+ changedContent.resetFailures();
+
+ content.put(changedContent.getId(), changedContent);
+ hasCatalogChanged = true;
+ }
+
+ public synchronized void remove(DownloadContent removedContent) {
awaitLoadingCatalogLocked();
- List<DownloadContent> downloadedContent = new ArrayList<>();
- for (DownloadContent content : this.content) {
- if (DownloadContent.STATE_DOWNLOADED == content.getState()) {
- downloadedContent.add(content);
+ if (!content.containsKey(removedContent.getId())) {
+ Log.w(LOGTAG, "Did not find content with matching id (" + removedContent.getId() + ") to remove");
+ return;
+ }
+
+ content.remove(removedContent.getId());
+ }
+
+ @Nullable
+ public synchronized DownloadContent getContentById(String id) {
+ return content.get(id);
+ }
+
+ public synchronized long getLastModified() {
+ awaitLoadingCatalogLocked();
+
+ long lastModified = 0;
+
+ for (DownloadContent currentContent : content.values()) {
+ if (currentContent.getLastModified() > lastModified) {
+ lastModified = currentContent.getLastModified();
}
}
- return downloadedContent;
- }
-
- public synchronized List<DownloadContent> getScheduledDownloads() {
- awaitLoadingCatalogLocked();
-
- List<DownloadContent> scheduledContent = new ArrayList<>();
- for (DownloadContent content : this.content) {
- if (DownloadContent.STATE_SCHEDULED == content.getState()) {
- scheduledContent.add(content);
- }
- }
-
- return scheduledContent;
- }
-
- public synchronized boolean hasScheduledDownloads() {
- awaitLoadingCatalogLocked();
-
- for (DownloadContent content : this.content) {
- if (DownloadContent.STATE_SCHEDULED == content.getState()) {
- return true;
- }
- }
-
- return false;
+ return lastModified;
}
public synchronized void scheduleDownload(DownloadContent content) {
content.setState(DownloadContent.STATE_SCHEDULED);
hasCatalogChanged = true;
}
public synchronized void markAsDownloaded(DownloadContent content) {
@@ -120,16 +157,21 @@ public class DownloadContentCatalog {
hasCatalogChanged = true;
}
public synchronized void markAsIgnored(DownloadContent content) {
content.setState(DownloadContent.STATE_IGNORED);
hasCatalogChanged = true;
}
+ public synchronized void markAsDeleted(DownloadContent content) {
+ content.setState(DownloadContent.STATE_DELETED);
+ hasCatalogChanged = true;
+ }
+
public synchronized void rememberFailure(DownloadContent content, int failureType) {
if (content.getFailures() >= MAX_FAILURES_UNTIL_PERMANENTLY_FAILED) {
Log.d(LOGTAG, "Maximum number of failures reached. Marking content has permanently failed.");
markAsPermanentlyFailed(content);
} else {
content.rememberFailure(failureType);
hasCatalogChanged = true;
@@ -170,53 +212,54 @@ public class DownloadContentCatalog {
protected synchronized void loadFromDisk() {
Log.d(LOGTAG, "Loading from disk");
if (hasLoadedCatalog) {
return;
}
- List<DownloadContent> content = new ArrayList<>();
+ ArrayMap<String, DownloadContent> loadedContent = new ArrayMap<>();
try {
JSONObject catalog;
synchronized (file) {
catalog = new JSONObject(new String(file.readFully(), "UTF-8"));
}
JSONArray array = catalog.getJSONArray(JSON_KEY_CONTENT);
for (int i = 0; i < array.length(); i++) {
- content.add(DownloadContent.fromJSON(array.getJSONObject(i)));
+ DownloadContent currentContent = DownloadContentBuilder.fromJSON(array.getJSONObject(i));
+ loadedContent.put(currentContent.getId(), currentContent);
}
} catch (FileNotFoundException e) {
Log.d(LOGTAG, "Catalog file does not exist: Bootstrapping initial catalog");
- content = DownloadContentBootstrap.createInitialDownloadContentList();
+ loadedContent = DownloadContentBootstrap.createInitialDownloadContentList();
} catch (JSONException e) {
Log.w(LOGTAG, "Unable to parse catalog JSON. Re-creating catalog.", e);
// Catalog seems to be broken. Re-create catalog:
- content = DownloadContentBootstrap.createInitialDownloadContentList();
+ loadedContent = DownloadContentBootstrap.createInitialDownloadContentList();
hasCatalogChanged = true; // Indicate that we want to persist the new catalog
} catch (UnsupportedEncodingException e) {
AssertionError error = new AssertionError("Should not happen: This device does not speak UTF-8");
error.initCause(e);
throw error;
} catch (IOException e) {
Log.d(LOGTAG, "Can't read catalog due to IOException", e);
}
- onCatalogLoaded(content);
+ onCatalogLoaded(loadedContent);
notifyAll();
Log.d(LOGTAG, "Loaded " + content.size() + " elements");
}
- protected void onCatalogLoaded(List<DownloadContent> content) {
+ protected void onCatalogLoaded(ArrayMap<String, DownloadContent> content) {
this.content = content;
this.hasLoadedCatalog = true;
}
protected synchronized void writeToDisk() {
if (!hasCatalogChanged) {
Log.v(LOGTAG, "Not persisting: Catalog has not changed");
return;
@@ -226,18 +269,18 @@ public class DownloadContentCatalog {
FileOutputStream outputStream = null;
synchronized (file) {
try {
outputStream = file.startWrite();
JSONArray array = new JSONArray();
- for (DownloadContent content : this.content) {
- array.put(content.toJSON());
+ for (DownloadContent currentContent : content.values()) {
+ array.put(DownloadContentBuilder.toJSON(currentContent));
}
JSONObject catalog = new JSONObject();
catalog.put(JSON_KEY_CONTENT, array);
outputStream.write(catalog.toString().getBytes("UTF-8"));
file.finishWrite(outputStream);
--- a/mobile/android/base/java/org/mozilla/gecko/util/Experiments.java
+++ b/mobile/android/base/java/org/mozilla/gecko/util/Experiments.java
@@ -37,16 +37,19 @@ public class Experiments {
public static final String CONTENT_NOTIFICATIONS_5PM = "content-notifications-5pm";
// Onboarding: "Features and Story". These experiments are determined
// on the client, they are not part of the server config.
public static final String ONBOARDING2_A = "onboarding2-a"; // Control: Single (blue) welcome screen
public static final String ONBOARDING2_B = "onboarding2-b"; // 4 static Feature slides
public static final String ONBOARDING2_C = "onboarding2-c"; // 4 static + 1 clickable (Data saving) Feature slides
+ // Synchronizing the catalog of downloadable content from Kinto
+ public static final String DOWNLOAD_CONTENT_CATALOG_SYNC = "download-content-catalog-sync";
+
public static final String PREF_ONBOARDING_VERSION = "onboarding_version";
private static volatile Boolean disabled = null;
/**
* Determines whether Switchboard is disabled by the MOZ_DISABLE_SWITCHBOARD
* environment variable. We need to read this value from the intent string
* extra because environment variables from our test harness aren't set
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -247,20 +247,22 @@ gbjar.sources += ['java/org/mozilla/geck
'db/URLMetadataTable.java',
'DevToolsAuthHelper.java',
'distribution/Distribution.java',
'distribution/ReferrerDescriptor.java',
'distribution/ReferrerReceiver.java',
'dlc/BaseAction.java',
'dlc/catalog/DownloadContent.java',
'dlc/catalog/DownloadContentBootstrap.java',
+ 'dlc/catalog/DownloadContentBuilder.java',
'dlc/catalog/DownloadContentCatalog.java',
'dlc/DownloadAction.java',
'dlc/DownloadContentService.java',
'dlc/StudyAction.java',
+ 'dlc/SyncAction.java',
'dlc/VerifyAction.java',
'DoorHangerPopup.java',
'DownloadsIntegration.java',
'DynamicToolbar.java',
'EditBookmarkDialog.java',
'EventDispatcher.java',
'favicons/cache/FaviconCache.java',
'favicons/cache/FaviconCacheElement.java',
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/resources/dlc_sync_deleted_item.json
@@ -0,0 +1,8 @@
+{
+ "data":[
+ {
+ "id":"c906275c-3747-fe27-426f-6187526a6f06",
+ "deleted": true
+ }
+ ]
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/resources/dlc_sync_single_font.json
@@ -0,0 +1,23 @@
+{
+ "data":[
+ {
+ "kind":"font",
+ "original": {
+ "mimetype":"application/x-font-ttf",
+ "filename":"CharisSILCompact-R.ttf",
+ "hash":"4ed509317f1bb441b185ea13bf1c9d19d1a0b396962efa3b5dc3190ad88f2067",
+ "size":1727656
+ },
+ "last_modified":1455710632607,
+ "attachment": {
+ "mimetype":"application/x-gzip",
+ "size":548720,
+ "hash":"960be4fc5a92c1dc488582b215d5d75429fd4ffbee463105d29992cd792a912e",
+ "location":"/attachments/0d28a72d-a51f-46f8-9e5a-f95c61de904e.gz",
+ "filename":"CharisSILCompact-R.ttf.gz"
+ },
+ "type":"asset-archive",
+ "id":"c906275c-3747-fe27-426f-6187526a6f06"
+ }
+ ]
+}
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestDownloadAction.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestDownloadAction.java
@@ -7,16 +7,17 @@ package org.mozilla.gecko.dlc;
import android.content.Context;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mozilla.gecko.background.testhelpers.TestRunner;
import org.mozilla.gecko.dlc.catalog.DownloadContent;
+import org.mozilla.gecko.dlc.catalog.DownloadContentBuilder;
import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
import org.robolectric.RuntimeEnvironment;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.net.HttpURLConnection;
@@ -75,17 +76,17 @@ public class TestDownloadAction {
* Scenario: Content is scheduled for download but already exists locally (with correct checksum).
*
* Verify that:
* * No download is performed for existing file
* * Content is marked as downloaded in the catalog
*/
@Test
public void testExistingAndVerifiedFilesAreNotDownloadedAgain() throws Exception {
- DownloadContent content = new DownloadContent.Builder().build();
+ DownloadContent content = new DownloadContentBuilder().build();
DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
doReturn(Collections.singletonList(content)).when(catalog).getScheduledDownloads();
DownloadAction action = spy(new DownloadAction(null));
doReturn(false).when(action).isActiveNetworkMetered(RuntimeEnvironment.application);
File file = mock(File.class);
@@ -144,17 +145,17 @@ public class TestDownloadAction {
* Scenario: A successful download has been performed.
*
* Verify that:
* * The content will be extracted to the destination
* * The content is marked as downloaded in the catalog
*/
@Test
public void testSuccessfulDownloadsAreMarkedAsDownloaded() throws Exception {
- DownloadContent content = new DownloadContent.Builder()
+ DownloadContent content = new DownloadContentBuilder()
.setKind(DownloadContent.KIND_FONT)
.setType(DownloadContent.TYPE_ASSET_ARCHIVE)
.build();
DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
doReturn(Collections.singletonList(content)).when(catalog).getScheduledDownloads();
DownloadAction action = spy(new DownloadAction(null));
@@ -181,17 +182,17 @@ public class TestDownloadAction {
*
* Verify that:
* * Range header is set in request
* * Content will be appended to existing file
* * Content will be marked as downloaded in catalog
*/
@Test
public void testResumingDownloadFromExistingFile() throws Exception {
- DownloadContent content = new DownloadContent.Builder()
+ DownloadContent content = new DownloadContentBuilder()
.setKind(DownloadContent.KIND_FONT)
.setType(DownloadContent.TYPE_ASSET_ARCHIVE)
.setSize(4223)
.build();
DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
doReturn(Collections.singletonList(content)).when(catalog).getScheduledDownloads();
@@ -229,17 +230,17 @@ public class TestDownloadAction {
* Scenario: Download fails with IOException.
*
* Verify that:
* * Partially downloaded file will not be deleted
* * Content will not be marked as downloaded in catalog
*/
@Test
public void testTemporaryFileIsNotDeletedAfterDownloadAborted() throws Exception {
- DownloadContent content = new DownloadContent.Builder()
+ DownloadContent content = new DownloadContentBuilder()
.setKind(DownloadContent.KIND_FONT)
.setType(DownloadContent.TYPE_ASSET_ARCHIVE)
.setSize(4223)
.build();
DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
doReturn(Collections.singletonList(content)).when(catalog).getScheduledDownloads();
@@ -270,17 +271,17 @@ public class TestDownloadAction {
*
* Verify that:
* * No download request is made
* * File is treated as completed and will be verified and extracted
* * Content is marked as downloaded in catalog
*/
@Test
public void testNoRequestIsSentIfFileIsAlreadyComplete() throws Exception {
- DownloadContent content = new DownloadContent.Builder()
+ DownloadContent content = new DownloadContentBuilder()
.setKind(DownloadContent.KIND_FONT)
.setType(DownloadContent.TYPE_ASSET_ARCHIVE)
.setSize(1337L)
.build();
DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
doReturn(Collections.singletonList(content)).when(catalog).getScheduledDownloads();
@@ -309,17 +310,17 @@ public class TestDownloadAction {
*
* Verify that:
* * Downloaded file is deleted
* * File will not be extracted
* * Content is not marked as downloaded in the catalog
*/
@Test
public void testTemporaryFileWillBeDeletedIfVerificationFails() throws Exception {
- DownloadContent content = new DownloadContent.Builder()
+ DownloadContent content = new DownloadContentBuilder()
.setKind(DownloadContent.KIND_FONT)
.setType(DownloadContent.TYPE_ASSET_ARCHIVE)
.setSize(1337L)
.build();
DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
doReturn(Collections.singletonList(content)).when(catalog).getScheduledDownloads();
@@ -491,17 +492,17 @@ public class TestDownloadAction {
verify(catalog, times(11)).rememberFailure(eq(content), anyInt());
}
private DownloadContent createFont() {
return createFontWithSize(102400L);
}
private DownloadContent createFontWithSize(long size) {
- return new DownloadContent.Builder()
+ return new DownloadContentBuilder()
.setKind(DownloadContent.KIND_FONT)
.setType(DownloadContent.TYPE_ASSET_ARCHIVE)
.setSize(size)
.build();
}
private DownloadContentCatalog mockCatalogWithScheduledDownloads(DownloadContent... content) {
DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestStudyAction.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestStudyAction.java
@@ -6,16 +6,17 @@
package org.mozilla.gecko.dlc;
import android.content.Context;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mozilla.gecko.background.testhelpers.TestRunner;
import org.mozilla.gecko.dlc.catalog.DownloadContent;
+import org.mozilla.gecko.dlc.catalog.DownloadContentBuilder;
import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
import org.robolectric.RuntimeEnvironment;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import static org.mockito.Mockito.any;
@@ -35,45 +36,45 @@ public class TestStudyAction {
*
* Verify that:
* * No download is scheduled
* * Download action is not started
*/
@Test
public void testPerformWithEmptyCatalog() {
DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
- when(catalog.getContentWithoutState()).thenReturn(new ArrayList<DownloadContent>());
+ when(catalog.getContentToStudy()).thenReturn(new ArrayList<DownloadContent>());
StudyAction action = spy(new StudyAction());
action.perform(RuntimeEnvironment.application, catalog);
- verify(catalog).getContentWithoutState();
+ verify(catalog).getContentToStudy();
verify(catalog, never()).markAsDownloaded(any(DownloadContent.class));
verify(action, never()).startDownloads(any(Context.class));
}
/**
* Scenario: Catalog contains two items that have not been downloaded yet.
*
* Verify that:
* * Both items are scheduled to be downloaded
*/
@Test
public void testPerformWithNewContent() {
- DownloadContent content1 = new DownloadContent.Builder()
+ DownloadContent content1 = new DownloadContentBuilder()
.setType(DownloadContent.TYPE_ASSET_ARCHIVE)
.setKind(DownloadContent.KIND_FONT)
.build();
- DownloadContent content2 = new DownloadContent.Builder()
+ DownloadContent content2 = new DownloadContentBuilder()
.setType(DownloadContent.TYPE_ASSET_ARCHIVE)
.setKind(DownloadContent.KIND_FONT)
.build();
DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
- when(catalog.getContentWithoutState()).thenReturn(Arrays.asList(content1, content2));
+ when(catalog.getContentToStudy()).thenReturn(Arrays.asList(content1, content2));
StudyAction action = spy(new StudyAction());
action.perform(RuntimeEnvironment.application, catalog);
verify(catalog).scheduleDownload(content1);
verify(catalog).scheduleDownload(content2);
}
@@ -97,22 +98,22 @@ public class TestStudyAction {
/**
* Scenario: Catalog contains unknown content.
*
* Verify that:
* * Unknown content is not scheduled for download.
*/
@Test
public void testPerformWithUnknownContent() {
- DownloadContent content = new DownloadContent.Builder()
+ DownloadContent content = new DownloadContentBuilder()
.setType("Unknown-Type")
.setKind("Unknown-Kind")
.build();
DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
- when(catalog.getContentWithoutState()).thenReturn(Collections.singletonList(content));
+ when(catalog.getContentToStudy()).thenReturn(Collections.singletonList(content));
StudyAction action = spy(new StudyAction());
action.perform(RuntimeEnvironment.application, catalog);
verify(catalog, never()).scheduleDownload(content);
}
}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestSyncAction.java
@@ -0,0 +1,254 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.dlc;
+
+import android.content.Context;
+import android.support.v4.util.ArrayMap;
+import android.support.v4.util.AtomicFile;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.dlc.catalog.DownloadContent;
+import org.mozilla.gecko.dlc.catalog.DownloadContentBuilder;
+import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
+import org.mozilla.gecko.util.IOUtils;
+import org.robolectric.RuntimeEnvironment;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.List;
+
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.anyLong;
+import static org.mockito.Matchers.anyString;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+/**
+ * SyncAction: Synchronize catalog from a (mocked) Kinto instance.
+ */
+@RunWith(TestRunner.class)
+public class TestSyncAction {
+ /**
+ * Scenario: The server returns an empty record set.
+ */
+ @Test
+ public void testEmptyResult() throws Exception {
+ SyncAction action = spy(new SyncAction());
+ doReturn(true).when(action).isSyncEnabledForClient(RuntimeEnvironment.application);
+ doReturn(new JSONArray()).when(action).fetchRawCatalog(anyLong());
+
+ action.perform(RuntimeEnvironment.application, mockCatalog());
+
+ verify(action, never()).createContent(anyCatalog(), anyJSONObject());
+ verify(action, never()).updateContent(anyCatalog(), anyJSONObject(), anyContent());
+ verify(action, never()).deleteContent(anyCatalog(), anyString());
+
+ verify(action, never()).startStudyAction(anyContext());
+ }
+
+ /**
+ * Scenario: The server returns an item that is not in the catalog yet.
+ */
+ @Test
+ public void testAddingNewContent() throws Exception {
+ SyncAction action = spy(new SyncAction());
+ doReturn(true).when(action).isSyncEnabledForClient(RuntimeEnvironment.application);
+ doReturn(fromFile("dlc_sync_single_font.json")).when(action).fetchRawCatalog(anyLong());
+
+ DownloadContentCatalog catalog = mockCatalog();
+
+ action.perform(RuntimeEnvironment.application, catalog);
+
+ // A new content item has been created
+ verify(action).createContent(anyCatalog(), anyJSONObject());
+
+ // No content item has been updated or deleted
+ verify(action, never()).updateContent(anyCatalog(), anyJSONObject(), anyContent());
+ verify(action, never()).deleteContent(anyCatalog(), anyString());
+
+ // A new item has been added to the catalog
+ ArgumentCaptor<DownloadContent> captor = ArgumentCaptor.forClass(DownloadContent.class);
+ verify(catalog).add(captor.capture());
+
+ // The item matches the values from the server response
+ DownloadContent content = captor.getValue();
+ Assert.assertEquals("c906275c-3747-fe27-426f-6187526a6f06", content.getId());
+ Assert.assertEquals("4ed509317f1bb441b185ea13bf1c9d19d1a0b396962efa3b5dc3190ad88f2067", content.getChecksum());
+ Assert.assertEquals("960be4fc5a92c1dc488582b215d5d75429fd4ffbee463105d29992cd792a912e", content.getDownloadChecksum());
+ Assert.assertEquals("CharisSILCompact-R.ttf", content.getFilename());
+ Assert.assertEquals(DownloadContent.KIND_FONT, content.getKind());
+ Assert.assertEquals("/attachments/0d28a72d-a51f-46f8-9e5a-f95c61de904e.gz", content.getLocation());
+ Assert.assertEquals(DownloadContent.TYPE_ASSET_ARCHIVE, content.getType());
+ Assert.assertEquals(1455710632607L, content.getLastModified());
+ Assert.assertEquals(1727656L, content.getSize());
+ Assert.assertEquals(DownloadContent.STATE_NONE, content.getState());
+ }
+
+ /**
+ * Scenario: The catalog contains one item and the server returns a new version.
+ */
+ @Test
+ public void testUpdatingExistingContent() throws Exception{
+ SyncAction action = spy(new SyncAction());
+ doReturn(true).when(action).isSyncEnabledForClient(RuntimeEnvironment.application);
+ doReturn(fromFile("dlc_sync_single_font.json")).when(action).fetchRawCatalog(anyLong());
+
+ DownloadContent existingContent = createTestContent("c906275c-3747-fe27-426f-6187526a6f06");
+ DownloadContentCatalog catalog = spy(new MockedContentCatalog(existingContent));
+
+ action.perform(RuntimeEnvironment.application, catalog);
+
+ // A content item has been updated
+ verify(action).updateContent(anyCatalog(), anyJSONObject(), eq(existingContent));
+
+ // No content item has been created or deleted
+ verify(action, never()).createContent(anyCatalog(), anyJSONObject());
+ verify(action, never()).deleteContent(anyCatalog(), anyString());
+
+ // An item has been updated in the catalog
+ ArgumentCaptor<DownloadContent> captor = ArgumentCaptor.forClass(DownloadContent.class);
+ verify(catalog).update(captor.capture());
+
+ // The item has the new values from the sever response
+ DownloadContent content = captor.getValue();
+ Assert.assertEquals("c906275c-3747-fe27-426f-6187526a6f06", content.getId());
+ Assert.assertEquals("4ed509317f1bb441b185ea13bf1c9d19d1a0b396962efa3b5dc3190ad88f2067", content.getChecksum());
+ Assert.assertEquals("960be4fc5a92c1dc488582b215d5d75429fd4ffbee463105d29992cd792a912e", content.getDownloadChecksum());
+ Assert.assertEquals("CharisSILCompact-R.ttf", content.getFilename());
+ Assert.assertEquals(DownloadContent.KIND_FONT, content.getKind());
+ Assert.assertEquals("/attachments/0d28a72d-a51f-46f8-9e5a-f95c61de904e.gz", content.getLocation());
+ Assert.assertEquals(DownloadContent.TYPE_ASSET_ARCHIVE, content.getType());
+ Assert.assertEquals(1455710632607L, content.getLastModified());
+ Assert.assertEquals(1727656L, content.getSize());
+ Assert.assertEquals(DownloadContent.STATE_UPDATED, content.getState());
+ }
+
+ /**
+ * Scenario: Catalog contains one item and the server returns that it has been deleted.
+ */
+ @Test
+ public void testDeletingExistingContent() throws Exception {
+ SyncAction action = spy(new SyncAction());
+ doReturn(true).when(action).isSyncEnabledForClient(RuntimeEnvironment.application);
+ doReturn(fromFile("dlc_sync_deleted_item.json")).when(action).fetchRawCatalog(anyLong());
+
+ final String id = "c906275c-3747-fe27-426f-6187526a6f06";
+ DownloadContent existingContent = createTestContent(id);
+ DownloadContentCatalog catalog = spy(new MockedContentCatalog(existingContent));
+
+ action.perform(RuntimeEnvironment.application, catalog);
+
+ // A content item has been deleted
+ verify(action).deleteContent(anyCatalog(), eq(id));
+
+ // No content item has been created or updated
+ verify(action, never()).createContent(anyCatalog(), anyJSONObject());
+ verify(action, never()).updateContent(anyCatalog(), anyJSONObject(), anyContent());
+
+ // An item has been marked for deletion in the catalog
+ ArgumentCaptor<DownloadContent> captor = ArgumentCaptor.forClass(DownloadContent.class);
+ verify(catalog).markAsDeleted(captor.capture());
+
+ DownloadContent content = captor.getValue();
+ Assert.assertEquals(id, content.getId());
+
+ List<DownloadContent> contentToDelete = catalog.getContentToDelete();
+ Assert.assertEquals(1, contentToDelete.size());
+ Assert.assertEquals(id, contentToDelete.get(0).getId());
+ }
+
+ /**
+ * Create a DownloadContent object with arbitrary data.
+ */
+ private DownloadContent createTestContent(String id) {
+ return new DownloadContentBuilder()
+ .setId(id)
+ .setLocation("/somewhere/something")
+ .setFilename("some.file")
+ .setChecksum("Some-checksum")
+ .setDownloadChecksum("Some-download-checksum")
+ .setLastModified(4223)
+ .setType("Some-type")
+ .setKind("Some-kind")
+ .setSize(27)
+ .setState(DownloadContent.STATE_SCHEDULED)
+ .build();
+ }
+
+ /**
+ * Create a Kinto response from a JSON file.
+ */
+ private JSONArray fromFile(String fileName) throws IOException, JSONException {
+ URL url = getClass().getResource("/" + fileName);
+ if (url == null) {
+ throw new FileNotFoundException(fileName);
+ }
+
+ InputStream inputStream = null;
+ ByteArrayOutputStream outputStream = null;
+
+ try {
+ inputStream = new BufferedInputStream(new FileInputStream(url.getPath()));
+ outputStream = new ByteArrayOutputStream();
+
+ IOUtils.copy(inputStream, outputStream);
+
+ JSONObject object = new JSONObject(outputStream.toString());
+
+ return object.getJSONArray("data");
+ } finally {
+ IOUtils.safeStreamClose(inputStream);
+ IOUtils.safeStreamClose(outputStream);
+ }
+ }
+
+ private static class MockedContentCatalog extends DownloadContentCatalog {
+ public MockedContentCatalog(DownloadContent content) {
+ super(mock(AtomicFile.class));
+
+ ArrayMap<String, DownloadContent> map = new ArrayMap<>();
+ map.put(content.getId(), content);
+
+ onCatalogLoaded(map);
+ }
+ }
+
+ private DownloadContentCatalog mockCatalog() {
+ return mock(DownloadContentCatalog.class);
+ }
+
+ private DownloadContentCatalog anyCatalog() {
+ return any(DownloadContentCatalog.class);
+ }
+
+ private JSONObject anyJSONObject() {
+ return any(JSONObject.class);
+ }
+
+ private DownloadContent anyContent() {
+ return any(DownloadContent.class);
+ }
+
+ private Context anyContext() {
+ return any(Context.class);
+ }
+}
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestVerifyAction.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestVerifyAction.java
@@ -6,16 +6,17 @@
package org.mozilla.gecko.dlc;
import android.content.Context;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mozilla.gecko.background.testhelpers.TestRunner;
import org.mozilla.gecko.dlc.catalog.DownloadContent;
+import org.mozilla.gecko.dlc.catalog.DownloadContentBuilder;
import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
import org.robolectric.RuntimeEnvironment;
import java.io.File;
import java.util.Collections;
import static org.mockito.Matchers.any;
import static org.mockito.Matchers.anyString;
@@ -35,17 +36,17 @@ public class TestVerifyAction {
/**
* Scenario: Downloaded file does not exist anymore.
*
* Verify that:
* * Content is re-scheduled for download.
*/
@Test
public void testReschedulingIfFileDoesNotExist() throws Exception {
- DownloadContent content = new DownloadContent.Builder().build();
+ DownloadContent content = new DownloadContentBuilder().build();
DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
when(catalog.getDownloadedContent()).thenReturn(Collections.singletonList(content));
File file = mock(File.class);
when(file.exists()).thenReturn(false);
VerifyAction action = spy(new VerifyAction());
doReturn(file).when(action).getDestinationFile(RuntimeEnvironment.application, content);
@@ -75,17 +76,17 @@ public class TestVerifyAction {
/**
* Scenario: Checksum of existing file does not match expectation.
*
* Verify that:
* * Content is re-scheduled for download.
*/
@Test
public void testReschedulingIfVerificationFailed() throws Exception {
- DownloadContent content = new DownloadContent.Builder().build();
+ DownloadContent content = new DownloadContentBuilder().build();
DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
when(catalog.getDownloadedContent()).thenReturn(Collections.singletonList(content));
File file = mock(File.class);
when(file.exists()).thenReturn(true);
VerifyAction action = spy(new VerifyAction());
doReturn(file).when(action).getDestinationFile(RuntimeEnvironment.application, content);
@@ -100,17 +101,17 @@ public class TestVerifyAction {
* Scenario: Downloaded file exists and has the correct checksum.
*
* Verify that:
* * No download is scheduled
* * Download action is not started
*/
@Test
public void testSuccessfulVerification() throws Exception {
- DownloadContent content = new DownloadContent.Builder().build();
+ DownloadContent content = new DownloadContentBuilder().build();
DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
when(catalog.getDownloadedContent()).thenReturn(Collections.singletonList(content));
File file = mock(File.class);
when(file.exists()).thenReturn(true);
VerifyAction action = spy(new VerifyAction());
doReturn(file).when(action).getDestinationFile(RuntimeEnvironment.application, content);
rename from mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/catalog/TestDownloadContent.java
rename to mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/catalog/TestDownloadContentBuilder.java
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/catalog/TestDownloadContent.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/catalog/TestDownloadContentBuilder.java
@@ -6,17 +6,17 @@ package org.mozilla.gecko.dlc.catalog;
import org.json.JSONException;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mozilla.gecko.background.testhelpers.TestRunner;
@RunWith(TestRunner.class)
-public class TestDownloadContent {
+public class TestDownloadContentBuilder {
/**
* Verify that the values passed to the builder are all set on the DownloadContent object.
*/
@Test
public void testBuilder() {
DownloadContent content = createTestContent();
Assert.assertEquals("Some-ID", content.getId());
@@ -30,17 +30,17 @@ public class TestDownloadContent {
Assert.assertEquals(27, content.getSize());
Assert.assertEquals(DownloadContent.STATE_SCHEDULED, content.getState());
}
/**
* Verify that a DownloadContent object exported to JSON and re-imported from JSON does not change.
*/
public void testJSONSerializationAndDeserialization() throws JSONException {
- DownloadContent content = DownloadContent.fromJSON(createTestContent().toJSON());
+ DownloadContent content = DownloadContentBuilder.fromJSON(DownloadContentBuilder.toJSON(createTestContent()));
Assert.assertEquals("Some-ID", content.getId());
Assert.assertEquals("/somewhere/something", content.getLocation());
Assert.assertEquals("some.file", content.getFilename());
Assert.assertEquals("Some-checksum", content.getChecksum());
Assert.assertEquals("Some-download-checksum", content.getDownloadChecksum());
Assert.assertEquals(4223, content.getLastModified());
Assert.assertEquals("Some-type", content.getType());
@@ -48,17 +48,17 @@ public class TestDownloadContent {
Assert.assertEquals(27, content.getSize());
Assert.assertEquals(DownloadContent.STATE_SCHEDULED, content.getState());
}
/**
* Create a DownloadContent object with arbitrary data.
*/
private DownloadContent createTestContent() {
- return new DownloadContent.Builder()
+ return new DownloadContentBuilder()
.setId("Some-ID")
.setLocation("/somewhere/something")
.setFilename("some.file")
.setChecksum("Some-checksum")
.setDownloadChecksum("Some-download-checksum")
.setLastModified(4223)
.setType("Some-type")
.setKind("Some-kind")
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/catalog/TestDownloadContentCatalog.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/catalog/TestDownloadContentCatalog.java
@@ -1,28 +1,30 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.gecko.dlc.catalog;
+import android.support.v4.util.ArrayMap;
import android.support.v4.util.AtomicFile;
import org.junit.Assert;
import org.junit.Assume;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mozilla.gecko.AppConstants;
import org.mozilla.gecko.background.testhelpers.TestRunner;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.util.Arrays;
import java.util.Collections;
+import java.util.Map;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
@@ -65,30 +67,30 @@ public class TestDownloadContentCatalog
Assume.assumeTrue("Fonts are excluded from build", AppConstants.MOZ_ANDROID_EXCLUDE_FONTS);
AtomicFile file = mock(AtomicFile.class);
doThrow(FileNotFoundException.class).when(file).readFully();
DownloadContentCatalog catalog = spy(new DownloadContentCatalog(file));
catalog.loadFromDisk();
- Assert.assertTrue("Catalog is not empty", catalog.getContentWithoutState().size() > 0);
+ Assert.assertTrue("Catalog is not empty", catalog.getContentToStudy().size() > 0);
}
/**
* Scenario: Schedule downloading an item from the catalog.
*
* Verify that:
* * Catalog has changed
*/
@Test
public void testCatalogHasChangedWhenDownloadIsScheduled() throws Exception {
DownloadContentCatalog catalog = spy(new DownloadContentCatalog(mock(AtomicFile.class)));
- DownloadContent content = new DownloadContent.Builder().build();
- catalog.onCatalogLoaded(Collections.singletonList(content));
+ DownloadContent content = new DownloadContentBuilder().build();
+ catalog.onCatalogLoaded(createMapOfContent(content));
Assert.assertFalse("Catalog has not changed", catalog.hasCatalogChanged());
catalog.scheduleDownload(content);
Assert.assertTrue("Catalog has changed", catalog.hasCatalogChanged());
}
@@ -96,18 +98,18 @@ public class TestDownloadContentCatalog
* Scenario: Mark an item in the catalog as downloaded.
*
* Verify that:
* * Catalog has changed
*/
@Test
public void testCatalogHasChangedWhenContentIsDownloaded() throws Exception {
DownloadContentCatalog catalog = spy(new DownloadContentCatalog(mock(AtomicFile.class)));
- DownloadContent content = new DownloadContent.Builder().build();
- catalog.onCatalogLoaded(Collections.singletonList(content));
+ DownloadContent content = new DownloadContentBuilder().build();
+ catalog.onCatalogLoaded(createMapOfContent(content));
Assert.assertFalse("Catalog has not changed", catalog.hasCatalogChanged());
catalog.markAsDownloaded(content);
Assert.assertTrue("Catalog has changed", catalog.hasCatalogChanged());
}
@@ -115,18 +117,18 @@ public class TestDownloadContentCatalog
* Scenario: Mark an item in the catalog as permanently failed.
*
* Verify that:
* * Catalog has changed
*/
@Test
public void testCatalogHasChangedIfDownloadHasFailedPermanently() throws Exception {
DownloadContentCatalog catalog = spy(new DownloadContentCatalog(mock(AtomicFile.class)));
- DownloadContent content = new DownloadContent.Builder().build();
- catalog.onCatalogLoaded(Collections.singletonList(content));
+ DownloadContent content = new DownloadContentBuilder().build();
+ catalog.onCatalogLoaded(createMapOfContent(content));
Assert.assertFalse("Catalog has not changed", catalog.hasCatalogChanged());
catalog.markAsPermanentlyFailed(content);
Assert.assertTrue("Catalog has changed", catalog.hasCatalogChanged());
}
@@ -134,18 +136,18 @@ public class TestDownloadContentCatalog
* Scenario: Mark an item in the catalog as ignored.
*
* Verify that:
* * Catalog has changed
*/
@Test
public void testCatalogHasChangedIfContentIsIgnored() throws Exception {
DownloadContentCatalog catalog = spy(new DownloadContentCatalog(mock(AtomicFile.class)));
- DownloadContent content = new DownloadContent.Builder().build();
- catalog.onCatalogLoaded(Collections.singletonList(content));
+ DownloadContent content = new DownloadContentBuilder().build();
+ catalog.onCatalogLoaded(createMapOfContent(content));
Assert.assertFalse("Catalog has not changed", catalog.hasCatalogChanged());
catalog.markAsIgnored(content);
Assert.assertTrue("Catalog has changed", catalog.hasCatalogChanged());
}
@@ -157,18 +159,18 @@ public class TestDownloadContentCatalog
* * After write: Catalog has not changed.
*/
@Test
public void testCatalogHasNotChangedAfterWritingToDisk() throws Exception {
AtomicFile file = mock(AtomicFile.class);
doReturn(mock(FileOutputStream.class)).when(file).startWrite();
DownloadContentCatalog catalog = spy(new DownloadContentCatalog(file));
- DownloadContent content = new DownloadContent.Builder().build();
- catalog.onCatalogLoaded(Collections.singletonList(content));
+ DownloadContent content = new DownloadContentBuilder().build();
+ catalog.onCatalogLoaded(createMapOfContent(content));
catalog.scheduleDownload(content);
Assert.assertTrue("Catalog has changed", catalog.hasCatalogChanged());
catalog.writeToDisk();
Assert.assertFalse("Catalog has not changed", catalog.hasCatalogChanged());
@@ -180,53 +182,62 @@ public class TestDownloadContentCatalog
* Verify that:
* * getContentWithoutState(), getDownloadedContent() and getScheduledDownloads() returns
* the correct items depenending on their state.
*/
@Test
public void testContentClassification() {
DownloadContentCatalog catalog = spy(new DownloadContentCatalog(mock(AtomicFile.class)));
- DownloadContent content1 = new DownloadContent.Builder().setState(DownloadContent.STATE_NONE).build();
- DownloadContent content2 = new DownloadContent.Builder().setState(DownloadContent.STATE_NONE).build();
- DownloadContent content3 = new DownloadContent.Builder().setState(DownloadContent.STATE_SCHEDULED).build();
- DownloadContent content4 = new DownloadContent.Builder().setState(DownloadContent.STATE_SCHEDULED).build();
- DownloadContent content5 = new DownloadContent.Builder().setState(DownloadContent.STATE_SCHEDULED).build();
- DownloadContent content6 = new DownloadContent.Builder().setState(DownloadContent.STATE_DOWNLOADED).build();
- DownloadContent content7 = new DownloadContent.Builder().setState(DownloadContent.STATE_FAILED).build();
- DownloadContent content8 = new DownloadContent.Builder().setState(DownloadContent.STATE_IGNORED).build();
- DownloadContent content9 = new DownloadContent.Builder().setState(DownloadContent.STATE_IGNORED).build();
+ DownloadContent content1 = new DownloadContentBuilder().setId("A").setState(DownloadContent.STATE_NONE).build();
+ DownloadContent content2 = new DownloadContentBuilder().setId("B").setState(DownloadContent.STATE_NONE).build();
+ DownloadContent content3 = new DownloadContentBuilder().setId("C").setState(DownloadContent.STATE_SCHEDULED).build();
+ DownloadContent content4 = new DownloadContentBuilder().setId("D").setState(DownloadContent.STATE_SCHEDULED).build();
+ DownloadContent content5 = new DownloadContentBuilder().setId("E").setState(DownloadContent.STATE_SCHEDULED).build();
+ DownloadContent content6 = new DownloadContentBuilder().setId("F").setState(DownloadContent.STATE_DOWNLOADED).build();
+ DownloadContent content7 = new DownloadContentBuilder().setId("G").setState(DownloadContent.STATE_FAILED).build();
+ DownloadContent content8 = new DownloadContentBuilder().setId("H").setState(DownloadContent.STATE_IGNORED).build();
+ DownloadContent content9 = new DownloadContentBuilder().setId("I").setState(DownloadContent.STATE_IGNORED).build();
+ DownloadContent content10 = new DownloadContentBuilder().setId("J").setState(DownloadContent.STATE_UPDATED).build();
+ DownloadContent content11 = new DownloadContentBuilder().setId("K").setState(DownloadContent.STATE_DELETED).build();
+ DownloadContent content12 = new DownloadContentBuilder().setId("L").setState(DownloadContent.STATE_DELETED).build();
+ catalog.onCatalogLoaded(createMapOfContent(content1, content2, content3, content4, content5, content6,
+ content7, content8, content9, content10, content11, content12));
- catalog.onCatalogLoaded(Arrays.asList(content1, content2, content3, content4, content5, content6,
- content7, content8, content9));
+ Assert.assertTrue(catalog.hasScheduledDownloads());
- Assert.assertEquals(2, catalog.getContentWithoutState().size());
+ Assert.assertEquals(3, catalog.getContentToStudy().size());
Assert.assertEquals(1, catalog.getDownloadedContent().size());
Assert.assertEquals(3, catalog.getScheduledDownloads().size());
+ Assert.assertEquals(2, catalog.getContentToDelete().size());
- Assert.assertTrue(catalog.getContentWithoutState().contains(content1));
- Assert.assertTrue(catalog.getContentWithoutState().contains(content2));
+ Assert.assertTrue(catalog.getContentToStudy().contains(content1));
+ Assert.assertTrue(catalog.getContentToStudy().contains(content2));
+ Assert.assertTrue(catalog.getContentToStudy().contains(content10));
Assert.assertTrue(catalog.getDownloadedContent().contains(content6));
Assert.assertTrue(catalog.getScheduledDownloads().contains(content3));
Assert.assertTrue(catalog.getScheduledDownloads().contains(content4));
Assert.assertTrue(catalog.getScheduledDownloads().contains(content5));
+
+ Assert.assertTrue(catalog.getContentToDelete().contains(content11));
+ Assert.assertTrue(catalog.getContentToDelete().contains(content12));
}
/**
* Scenario: Calling rememberFailure() on a catalog with varying values
*/
@Test
public void testRememberingFailures() {
DownloadContentCatalog catalog = new DownloadContentCatalog(mock(AtomicFile.class));
Assert.assertFalse(catalog.hasCatalogChanged());
- DownloadContent content = new DownloadContent.Builder().build();
+ DownloadContent content = new DownloadContentBuilder().build();
Assert.assertEquals(0, content.getFailures());
catalog.rememberFailure(content, 42);
Assert.assertEquals(1, content.getFailures());
Assert.assertTrue(catalog.hasCatalogChanged());
catalog.rememberFailure(content, 42);
Assert.assertEquals(2, content.getFailures());
@@ -245,23 +256,31 @@ public class TestDownloadContentCatalog
*
* Verify that:
* * Content is marked as permanently failed
*/
@Test
public void testContentWillBeMarkedAsPermanentlyFailedAfterMultipleFailures() {
DownloadContentCatalog catalog = new DownloadContentCatalog(mock(AtomicFile.class));
- DownloadContent content = new DownloadContent.Builder().build();
+ DownloadContent content = new DownloadContentBuilder().build();
Assert.assertEquals(DownloadContent.STATE_NONE, content.getState());
for (int i = 0; i < 10; i++) {
catalog.rememberFailure(content, 42);
Assert.assertEquals(i + 1, content.getFailures());
Assert.assertEquals(DownloadContent.STATE_NONE, content.getState());
}
catalog.rememberFailure(content, 42);
Assert.assertEquals(10, content.getFailures());
Assert.assertEquals(DownloadContent.STATE_FAILED, content.getState());
}
+
+ private ArrayMap<String, DownloadContent> createMapOfContent(DownloadContent... content) {
+ ArrayMap<String, DownloadContent> map = new ArrayMap<>();
+ for (DownloadContent currentContent : content) {
+ map.put(currentContent.getId(), currentContent);
+ }
+ return map;
+ }
}