summaryrefslogtreecommitdiffstats
path: root/mobile/android/base/java/org/mozilla/gecko/dlc/SyncAction.java
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/base/java/org/mozilla/gecko/dlc/SyncAction.java')
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/dlc/SyncAction.java263
1 files changed, 263 insertions, 0 deletions
diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/SyncAction.java b/mobile/android/base/java/org/mozilla/gecko/dlc/SyncAction.java
new file mode 100644
index 000000000..104bdad18
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/SyncAction.java
@@ -0,0 +1,263 @@
+/* -*- 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 com.keepsafe.switchboard.SwitchBoard;
+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.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_KEY_ATTACHMENT = "attachment";
+ private static final String KINTO_KEY_ORIGINAL = "original";
+
+ 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/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);
+
+ if (!isDeleted) {
+ JSONObject attachment = object.getJSONObject(KINTO_KEY_ATTACHMENT);
+ if (attachment.isNull(KINTO_KEY_ORIGINAL))
+ throw new JSONException(String.format("Old Attachment Format"));
+ }
+
+ 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,attachment.original.filename,attachment.original.hash,attachment.hash,type,kind,attachment.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 SwitchBoard.isInExperiment(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);
+ }
+ }
+}