diff options
author | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
---|---|---|
committer | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
commit | 5f8de423f190bbb79a62f804151bc24824fa32d8 (patch) | |
tree | 10027f336435511475e392454359edea8e25895d /mobile/android/base/java/org/mozilla/gecko/dlc | |
parent | 49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff) | |
download | UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip |
Add m-esr52 at 52.6.0
Diffstat (limited to 'mobile/android/base/java/org/mozilla/gecko/dlc')
11 files changed, 1982 insertions, 0 deletions
diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/BaseAction.java b/mobile/android/base/java/org/mozilla/gecko/dlc/BaseAction.java new file mode 100644 index 000000000..28d6b238d --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/dlc/BaseAction.java @@ -0,0 +1,166 @@ +/* -*- 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.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 org.mozilla.gecko.util.ProxySelector; + +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.URI; +import java.net.URISyntaxException; + +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 { + private static final long serialVersionUID = -2246772819507370734L; + + @IntDef({MEMORY, DISK_IO, SERVER, NETWORK}) + public @interface ErrorType {} + public static final int MEMORY = 1; + public static final int DISK_IO = 2; + public static final int SERVER = 3; + public static final int NETWORK = 4; + + private int errorType; + + public RecoverableDownloadContentException(@ErrorType int errorType, String message) { + super(message); + this.errorType = errorType; + } + + public RecoverableDownloadContentException(@ErrorType int errorType, Throwable cause) { + super(cause); + this.errorType = errorType; + } + + @ErrorType + public int getErrorType() { + return errorType; + } + + /** + * Should this error be counted as failure? If this type of error will happen multiple times in a row then this + * error will be treated as permanently and the operation will not be tried again until the content changes. + */ + public boolean shouldBeCountedAsFailure() { + if (NETWORK == errorType) { + return false; // Always retry after network errors + } + + return true; + } + } + + /** + * If this exception is thrown the content will be marked as unrecoverable, permanently failed and we will not try + * downloading it again - until a newer version of the content is available. + */ + /* package-private */ static class UnrecoverableDownloadContentException extends Exception { + private static final long serialVersionUID = 8956080754787367105L; + + public UnrecoverableDownloadContentException(String message) { + super(message); + } + + public UnrecoverableDownloadContentException(Throwable cause) { + super(cause); + } + } + + public abstract void perform(Context context, DownloadContentCatalog catalog); + + protected File getDestinationFile(Context context, DownloadContent content) + throws UnrecoverableDownloadContentException, RecoverableDownloadContentException { + if (content.isFont()) { + File destinationDirectory = new File(context.getApplicationInfo().dataDir, "fonts"); + + if (!destinationDirectory.exists() && !destinationDirectory.mkdirs()) { + throw new RecoverableDownloadContentException(RecoverableDownloadContentException.DISK_IO, + "Destination directory does not exist and cannot be created"); + } + + return new File(destinationDirectory, content.getFilename()); + } + + // Unrecoverable: We downloaded a file and we don't know what to do with it (Should not happen) + throw new UnrecoverableDownloadContentException("Can't determine destination for kind: " + content.getKind()); + } + + protected boolean verify(File file, String expectedChecksum) + throws RecoverableDownloadContentException, UnrecoverableDownloadContentException { + InputStream inputStream = null; + + try { + inputStream = new BufferedInputStream(new FileInputStream(file)); + + byte[] ctx = NativeCrypto.sha256init(); + if (ctx == null) { + throw new RecoverableDownloadContentException(RecoverableDownloadContentException.MEMORY, + "Could not create SHA-256 context"); + } + + byte[] buffer = new byte[4096]; + int read; + + while ((read = inputStream.read(buffer)) != -1) { + NativeCrypto.sha256update(ctx, buffer, read); + } + + String actualChecksum = Utils.byte2Hex(NativeCrypto.sha256finalize(ctx)); + + if (!actualChecksum.equalsIgnoreCase(expectedChecksum)) { + Log.w(LOGTAG, "Checksums do not match. Expected=" + expectedChecksum + ", Actual=" + actualChecksum); + return false; + } + + 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 { + try { + System.setProperty("http.keepAlive", "true"); + + HttpURLConnection connection = (HttpURLConnection) ProxySelector.openConnectionWithProxy(new URI(url)); + 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); + } catch (URISyntaxException e) { + throw new UnrecoverableDownloadContentException(e); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/CleanupAction.java b/mobile/android/base/java/org/mozilla/gecko/dlc/CleanupAction.java new file mode 100644 index 000000000..e44704c6c --- /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. + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadAction.java b/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadAction.java new file mode 100644 index 000000000..8618d4699 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadAction.java @@ -0,0 +1,325 @@ +/* -*- 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.ConnectivityManager; +import android.net.NetworkInfo; +import android.support.v4.net.ConnectivityManagerCompat; +import android.util.Log; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.dlc.catalog.DownloadContent; +import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog; +import org.mozilla.gecko.util.HardwareUtils; +import org.mozilla.gecko.util.IOUtils; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.zip.GZIPInputStream; + +/** + * Download content that has been scheduled during "study" or "verify". + */ +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://fennec-catalog.cdn.mozilla.net/"; + + public interface Callback { + void onContentDownloaded(DownloadContent content); + } + + private Callback callback; + + public DownloadAction(Callback callback) { + this.callback = callback; + } + + public void perform(Context context, DownloadContentCatalog catalog) { + Log.d(LOGTAG, "Downloading content.."); + + if (!isConnectedToNetwork(context)) { + Log.d(LOGTAG, "No connected network available. Postponing download."); + // TODO: Reschedule download (bug 1209498) + return; + } + + if (isActiveNetworkMetered(context)) { + Log.d(LOGTAG, "Network is metered. Postponing download."); + // TODO: Reschedule download (bug 1209498) + return; + } + + for (DownloadContent content : catalog.getScheduledDownloads()) { + Log.d(LOGTAG, "Downloading: " + content); + + File temporaryFile = null; + + try { + File destinationFile = getDestinationFile(context, content); + if (destinationFile.exists() && verify(destinationFile, content.getChecksum())) { + Log.d(LOGTAG, "Content already exists and is up-to-date."); + catalog.markAsDownloaded(content); + continue; + } + + temporaryFile = createTemporaryFile(context, content); + + if (!hasEnoughDiskSpace(content, destinationFile, temporaryFile)) { + Log.d(LOGTAG, "Not enough disk space to save content. Skipping download."); + continue; + } + + // TODO: Check space on disk before downloading content (bug 1220145) + final String url = createDownloadURL(content); + + if (!temporaryFile.exists() || temporaryFile.length() < content.getSize()) { + download(url, temporaryFile); + } + + if (!verify(temporaryFile, content.getDownloadChecksum())) { + Log.w(LOGTAG, "Wrong checksum after download, content=" + content.getId()); + temporaryFile.delete(); + continue; + } + + if (!content.isAssetArchive()) { + Log.e(LOGTAG, "Downloaded content is not of type 'asset-archive': " + content.getType()); + temporaryFile.delete(); + continue; + } + + extract(temporaryFile, destinationFile, content.getChecksum()); + + catalog.markAsDownloaded(content); + + Log.d(LOGTAG, "Successfully downloaded: " + content); + + if (callback != null) { + callback.onContentDownloaded(content); + } + + if (temporaryFile != null && temporaryFile.exists()) { + temporaryFile.delete(); + } + } catch (RecoverableDownloadContentException e) { + Log.w(LOGTAG, "Downloading content failed (Recoverable): " + content, e); + + if (e.shouldBeCountedAsFailure()) { + catalog.rememberFailure(content, e.getErrorType()); + } + + // TODO: Reschedule download (bug 1209498) + } catch (UnrecoverableDownloadContentException e) { + Log.w(LOGTAG, "Downloading content failed (Unrecoverable): " + content, e); + + catalog.markAsPermanentlyFailed(content); + + if (temporaryFile != null && temporaryFile.exists()) { + temporaryFile.delete(); + } + } + } + + Log.v(LOGTAG, "Done"); + } + + protected void download(String source, File temporaryFile) + throws RecoverableDownloadContentException, UnrecoverableDownloadContentException { + InputStream inputStream = null; + OutputStream outputStream = null; + + HttpURLConnection connection = null; + + try { + 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 != 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. + throw new UnrecoverableDownloadContentException("(Unrecoverable) Download failed. Status code: " + status); + } else { + // HttpsUrlConnection: -1 (No valid response code) + // 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 == 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); + } finally { + IOUtils.safeStreamClose(inputStream); + IOUtils.safeStreamClose(outputStream); + + if (connection != null) { + connection.disconnect(); + } + } + } + + protected OutputStream openFile(File file, boolean append) throws FileNotFoundException { + return new BufferedOutputStream(new FileOutputStream(file, append)); + } + + protected void extract(File sourceFile, File destinationFile, String checksum) + throws UnrecoverableDownloadContentException, RecoverableDownloadContentException { + InputStream inputStream = null; + OutputStream outputStream = null; + File temporaryFile = null; + + try { + File destinationDirectory = destinationFile.getParentFile(); + if (!destinationDirectory.exists() && !destinationDirectory.mkdirs()) { + throw new IOException("Destination directory does not exist and cannot be created"); + } + + temporaryFile = new File(destinationDirectory, destinationFile.getName() + ".tmp"); + + inputStream = new GZIPInputStream(new BufferedInputStream(new FileInputStream(sourceFile))); + outputStream = new BufferedOutputStream(new FileOutputStream(temporaryFile)); + + IOUtils.copy(inputStream, outputStream); + + inputStream.close(); + outputStream.close(); + + if (!verify(temporaryFile, checksum)) { + Log.w(LOGTAG, "Checksum of extracted file does not match."); + return; + } + + move(temporaryFile, destinationFile); + } catch (IOException e) { + // We could not extract to the destination: Keep temporary file and try again next time we run. + throw new RecoverableDownloadContentException(RecoverableDownloadContentException.DISK_IO, e); + } finally { + IOUtils.safeStreamClose(inputStream); + IOUtils.safeStreamClose(outputStream); + + if (temporaryFile != null && temporaryFile.exists()) { + temporaryFile.delete(); + } + } + } + + protected boolean isConnectedToNetwork(Context context) { + ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo networkInfo = manager.getActiveNetworkInfo(); + + return networkInfo != null && networkInfo.isConnected(); + } + + protected boolean isActiveNetworkMetered(Context context) { + return ConnectivityManagerCompat.isActiveNetworkMetered( + (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE)); + } + + protected String createDownloadURL(DownloadContent content) { + final String location = content.getLocation(); + + 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()) { + // Recoverable: File system might not be mounted NOW and we didn't download anything yet anyways. + throw new RecoverableDownloadContentException(RecoverableDownloadContentException.DISK_IO, + "Could not create cache directory: " + cacheDirectory); + } + + return new File(cacheDirectory, content.getDownloadChecksum() + "-" + content.getId()); + } + + protected void move(File temporaryFile, File destinationFile) + throws RecoverableDownloadContentException, UnrecoverableDownloadContentException { + if (!temporaryFile.renameTo(destinationFile)) { + Log.d(LOGTAG, "Could not move temporary file to destination. Trying to copy.."); + copy(temporaryFile, destinationFile); + temporaryFile.delete(); + } + } + + protected void copy(File temporaryFile, File destinationFile) + throws RecoverableDownloadContentException, UnrecoverableDownloadContentException { + InputStream inputStream = null; + OutputStream outputStream = null; + + try { + File destinationDirectory = destinationFile.getParentFile(); + if (!destinationDirectory.exists() && !destinationDirectory.mkdirs()) { + throw new IOException("Destination directory does not exist and cannot be created"); + } + + inputStream = new BufferedInputStream(new FileInputStream(temporaryFile)); + outputStream = new BufferedOutputStream(new FileOutputStream(destinationFile)); + + IOUtils.copy(inputStream, outputStream); + + inputStream.close(); + outputStream.close(); + } catch (IOException e) { + // We could not copy the temporary file to its destination: Keep the temporary file and + // try again the next time we run. + throw new RecoverableDownloadContentException(RecoverableDownloadContentException.DISK_IO, e); + } finally { + IOUtils.safeStreamClose(inputStream); + IOUtils.safeStreamClose(outputStream); + } + } + + protected boolean hasEnoughDiskSpace(DownloadContent content, File destinationFile, File temporaryFile) { + final File temporaryDirectory = temporaryFile.getParentFile(); + if (temporaryDirectory.getUsableSpace() < content.getSize()) { + return false; + } + + final File destinationDirectory = destinationFile.getParentFile(); + // We need some more space to extract the file (getSize() returns the uncompressed size) + if (destinationDirectory.getUsableSpace() < content.getSize() * 2) { + return false; + } + + return true; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadContentService.java b/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadContentService.java new file mode 100644 index 000000000..3729cf2e0 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadContentService.java @@ -0,0 +1,144 @@ +/* -*- 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 org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.dlc.catalog.DownloadContent; +import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog; +import org.mozilla.gecko.util.HardwareUtils; + +import android.app.IntentService; +import android.content.ComponentName; +import android.content.Context; +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); + intent.setComponent(new ComponentName(context, DownloadContentService.class)); + context.startService(intent); + } + + 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() { + super.onCreate(); + + catalog = new DownloadContentCatalog(this); + } + + protected void onHandleIntent(Intent intent) { + if (!AppConstants.MOZ_ANDROID_DOWNLOAD_CONTENT_SERVICE) { + Log.w(LOGTAG, "Download content is not enabled. Stop."); + return; + } + + if (!HardwareUtils.isSupportedSystem()) { + // This service is running very early before checks in BrowserApp can prevent us from running. + Log.w(LOGTAG, "System is not supported. Stop."); + return; + } + + if (intent == null) { + return; + } + + final BaseAction action; + + switch (intent.getAction()) { + case ACTION_STUDY_CATALOG: + action = new StudyAction(); + break; + + case ACTION_DOWNLOAD_CONTENT: + action = new DownloadAction(new DownloadAction.Callback() { + @Override + public void onContentDownloaded(DownloadContent content) { + if (content.isFont()) { + GeckoAppShell.notifyObservers("Fonts:Reload", ""); + } + } + }); + 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(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/StudyAction.java b/mobile/android/base/java/org/mozilla/gecko/dlc/StudyAction.java new file mode 100644 index 000000000..e15a17bbe --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/dlc/StudyAction.java @@ -0,0 +1,81 @@ +/* -*- 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.os.Build; +import android.text.TextUtils; +import android.util.Log; + +import org.mozilla.gecko.dlc.catalog.DownloadContent; +import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog; +import org.mozilla.gecko.util.ContextUtils; + +/** + * 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.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)) { + final String appVersion = ContextUtils.getCurrentPackageInfo(context).versionName; + if (!appVersion.matches(appVersionPattern)) { + Log.d(LOGTAG, String.format("App version (%s) does not match pattern: %s", appVersion, appVersionPattern)); + return false; + } + } + + // There are no patterns or all patterns have matched. + return true; + } + + protected void startDownloads(Context context) { + DownloadContentService.startDownloads(context); + } +} 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); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/VerifyAction.java b/mobile/android/base/java/org/mozilla/gecko/dlc/VerifyAction.java new file mode 100644 index 000000000..e96a62eae --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/dlc/VerifyAction.java @@ -0,0 +1,63 @@ +/* -*- 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.util.Log; + +import org.mozilla.gecko.dlc.catalog.DownloadContent; +import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog; + +import java.io.File; + +/** + * Verify: Validate downloaded content. Does it still exist and does it have the correct checksum? + */ +public class VerifyAction extends BaseAction { + private static final String LOGTAG = "DLCVerifyAction"; + + @Override + public void perform(Context context, DownloadContentCatalog catalog) { + Log.d(LOGTAG, "Verifying catalog.."); + + for (DownloadContent content : catalog.getDownloadedContent()) { + try { + File destinationFile = getDestinationFile(context, content); + + if (!destinationFile.exists()) { + Log.d(LOGTAG, "Downloaded content does not exist anymore: " + content); + + // This file does not exist even though it is marked as downloaded in the catalog. Scheduling a + // download to fetch it again. + catalog.scheduleDownload(content); + continue; + } + + if (!verify(destinationFile, content.getChecksum())) { + catalog.scheduleDownload(content); + Log.d(LOGTAG, "Wrong checksum. Scheduling download: " + content); + continue; + } + + Log.v(LOGTAG, "Content okay: " + content); + } catch (UnrecoverableDownloadContentException e) { + Log.w(LOGTAG, "Unrecoverable exception while verifying downloaded file", e); + } catch (RecoverableDownloadContentException e) { + // That's okay, we are just verifying already existing content. No log. + } + } + + if (catalog.hasScheduledDownloads()) { + startDownloads(context); + } + + Log.v(LOGTAG, "Done"); + } + + protected void startDownloads(Context context) { + DownloadContentService.startDownloads(context); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContent.java b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContent.java new file mode 100644 index 000000000..61f7992ca --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContent.java @@ -0,0 +1,189 @@ +/* -*- 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.annotation.IntDef; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.StringDef; + +public class DownloadContent { + @IntDef({STATE_NONE, STATE_SCHEDULED, STATE_DOWNLOADED, STATE_FAILED, 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_UPDATED = 4; + public static final int STATE_DELETED = 5; + + @StringDef({TYPE_ASSET_ARCHIVE}) + public @interface Type {} + public static final String TYPE_ASSET_ARCHIVE = "asset-archive"; + + @StringDef({KIND_FONT, KIND_HYPHENATION_DICTIONARY}) + public @interface Kind {} + public static final String KIND_FONT = "font"; + public static final String KIND_HYPHENATION_DICTIONARY = "hyphenation"; + + private final String id; + 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; + + /* 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, 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; + } + + public String getLocation() { + return location; + } + + public long getLastModified() { + return lastModified; + } + + public String getFilename() { + return filename; + } + + public String getChecksum() { + return checksum; + } + + public String getDownloadChecksum() { + return downloadChecksum; + } + + public long getSize() { + return size; + } + + public boolean isFont() { + return KIND_FONT.equals(kind); + } + + public boolean isHyphenationDictionary() { + return KIND_HYPHENATION_DICTIONARY.equals(kind); + } + + /** + *Checks whether the content to be downloaded is a known content. + *Currently it checks whether the type is "Asset Archive" and is of kind + *"Font" or "Hyphenation Dictionary". + */ + public boolean isKnownContent() { + return ((isFont() || isHyphenationDictionary()) && isAssetArchive()); + } + + 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 String getAppVersionPattern() { + return appVersionPattern; + } + + public String getAndroidApiPattern() { + return androidApiPattern; + } + + public String getAppIdPattern() { + return appIdPattern; + } + + public DownloadContentBuilder buildUpon() { + return DownloadContentBuilder.buildUpon(this); + } + + + public String toString() { + return String.format("[%s,%s] %s (%d bytes) %s", getType(), getKind(), getId(), getSize(), getChecksum()); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentBootstrap.java b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentBootstrap.java new file mode 100644 index 000000000..40c804573 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentBootstrap.java @@ -0,0 +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 java.util.Arrays; +import java.util.List; + +/* package-private */ class DownloadContentBootstrap { + public static ArrayMap<String, DownloadContent> createInitialDownloadContentList() { + if (!AppConstants.MOZ_ANDROID_EXCLUDE_FONTS) { + // We are packaging fonts. There's nothing we want to download; + return new ArrayMap<>(); + } + + List<DownloadContent> initialList = Arrays.asList( + new DownloadContentBuilder() + .setId("c40929cf-7f4c-fa72-3dc9-12cadf56905d") + .setLocation("fennec/catalog/f63e5f92-793c-4574-a2d7-fbc50242b8cb.gz") + .setFilename("CharisSILCompact-B.ttf") + .setChecksum("699d958b492eda0cc2823535f8567d0393090e3842f6df3c36dbe7239cb80b6d") + .setDownloadChecksum("a9f9b34fed353169a88cc159b8f298cb285cce0b8b0f979c22a7d85de46f0532") + .setSize(1676072) + .setKind("font") + .setType("asset-archive") + .build(), + + new DownloadContentBuilder() + .setId("6d265876-85ed-0917-fdc8-baf583ca2cba") + .setLocation("fennec/catalog/19af6c88-09d9-4d6c-805e-cfebb8699a6c.gz") + .setFilename("CharisSILCompact-BI.ttf") + .setChecksum("82465e747b4f41471dbfd942842b2ee810749217d44b55dbc43623b89f9c7d9b") + .setDownloadChecksum("2be26671039a5e2e4d0360a948b4fa42048171133076a3bb6173d93d4b9cd55b") + .setSize(1667812) + .setKind("font") + .setType("asset-archive") + .build(), + + new DownloadContentBuilder() + .setId("8460dc6d-d129-fd1a-24b6-343dbf6531dd") + .setLocation("fennec/catalog/f35a384a-90ea-41c6-a957-bb1845de97eb.gz") + .setFilename("CharisSILCompact-I.ttf") + .setChecksum("ab3ed6f2a4d4c2095b78227bd33155d7ccd05a879c107a291912640d4d64f767") + .setDownloadChecksum("38a6469041c02624d43dfd41d2dd745e3e3211655e616188f65789a90952a1e9") + .setSize(1693988) + .setKind("font") + .setType("asset-archive") + .build(), + + new DownloadContentBuilder() + .setId("c906275c-3747-fe27-426f-6187526a6f06") + .setLocation("fennec/catalog/8c3bec92-d2df-4789-8c4a-0f523f026d96.gz") + .setFilename("CharisSILCompact-R.ttf") + .setChecksum("4ed509317f1bb441b185ea13bf1c9d19d1a0b396962efa3b5dc3190ad88f2067") + .setDownloadChecksum("7c2ec1f550c2005b75383b878f737266b5f0b1c82679dd886c8bbe30c82e340e") + .setSize(1727656) + .setKind("font") + .setType("asset-archive") + .build(), + + new DownloadContentBuilder() + .setId("ff5deecc-6ecc-d816-bb51-65face460119") + .setLocation("fennec/catalog/ea115d71-e2ac-4609-853e-c978780776b1.gz") + .setFilename("ClearSans-Bold.ttf") + .setChecksum("385d0a293c1714770e198f7c762ab32f7905a0ed9d2993f69d640bd7232b4b70") + .setDownloadChecksum("0d3c22bef90e7096f75b331bb7391de3aa43017e10d61041cd3085816db4919a") + .setSize(140136) + .setKind("font") + .setType("asset-archive") + .build(), + + new DownloadContentBuilder() + .setId("a173d1db-373b-ce42-1335-6b3285cfdebd") + .setLocation("fennec/catalog/0838e513-2d99-4e53-b58f-6b970f6548c6.gz") + .setFilename("ClearSans-BoldItalic.ttf") + .setChecksum("7bce66864e38eecd7c94b6657b9b38c35ebfacf8046bfb1247e08f07fe933198") + .setDownloadChecksum("de0903164dde1ad3768d0bd6dec949871d6ab7be08f573d9d70f38c138a22e37") + .setSize(156124) + .setKind("font") + .setType("asset-archive") + .build(), + + new DownloadContentBuilder() + .setId("e65c66df-0088-940d-ca5c-207c22118c0e") + .setLocation("fennec/catalog/7550fa42-0947-478c-a5f0-5ea1bbb6ba27.gz") + .setFilename("ClearSans-Italic.ttf") + .setChecksum("87c13c5fbae832e4f85c3bd46fcbc175978234f39be5fe51c4937be4cbff3b68") + .setDownloadChecksum("6e323db3115005dd0e96d2422db87a520f9ae426de28a342cd6cc87b55601d87") + .setSize(155672) + .setKind("font") + .setType("asset-archive") + .build(), + + new DownloadContentBuilder() + .setId("25610abb-5dc8-fd75-40e7-990507f010c4") + .setLocation("fennec/catalog/dd9bee7d-d784-476b-a3dd-69af8e516487.gz") + .setFilename("ClearSans-Light.ttf") + .setChecksum("e4885f6188e7a8587f5621c077c6c1f5e8d3739dffc8f4d055c2ba87568c750a") + .setDownloadChecksum("19d4f7c67176e9e254c61420da9c7363d9fe5e6b4bb9d61afa4b3b574280714f") + .setSize(145976) + .setKind("font") + .setType("asset-archive") + .build(), + + new DownloadContentBuilder() + .setId("ffe40339-a096-2262-c3f8-54af75c81fe6") + .setLocation("fennec/catalog/bc5ada8c-8cfc-443d-93d7-dc5f98138a07.gz") + .setFilename("ClearSans-Medium.ttf") + .setChecksum("5d0e0115f3a3ed4be3eda6d7eabb899bb9a361292802e763d53c72e00f629da1") + .setDownloadChecksum("edec86dab3ad2a97561cb41b584670262a48bed008c57bb587ee05ca47fb067f") + .setSize(148892) + .setKind("font") + .setType("asset-archive") + .build(), + + new DownloadContentBuilder() + .setId("139a94be-ac69-0264-c9cc-8f2d071fd29d") + .setLocation("fennec/catalog/0490c768-6178-49c2-af88-9f8769ff3167.gz") + .setFilename("ClearSans-MediumItalic.ttf") + .setChecksum("937dda88b26469306258527d38e42c95e27e7ebb9f05bd1d7c5d706a3c9541d7") + .setDownloadChecksum("34edbd1b325dbffe7791fba8dd2d19852eb3c2fe00cff517ea2161ddc424ee22") + .setSize(155228) + .setKind("font") + .setType("asset-archive") + .build(), + + new DownloadContentBuilder() + .setId("b887012a-01e1-7c94-fdcb-ca44d5b974a2") + .setLocation("fennec/catalog/78205bf8-c668-41b1-b68f-afd54f98713b.gz") + .setFilename("ClearSans-Regular.ttf") + .setChecksum("9b91bbdb95ffa6663da24fdaa8ee06060cd0a4d2dceaf1ffbdda00e04915ee5b") + .setDownloadChecksum("a72f1420b4da1ba9e6797adac34f08e72f94128a85e56542d5e6a8080af5f08a") + .setSize(142572) + .setKind("font") + .setType("asset-archive") + .build(), + + new DownloadContentBuilder() + .setId("c8703652-d317-0356-0bf8-95441a5b2c9b") + .setLocation("fennec/catalog/3570f44f-9440-4aa0-abd0-642eaf2a1aa0.gz") + .setFilename("ClearSans-Thin.ttf") + .setChecksum("07b0db85a3ad99afeb803f0f35631436a7b4c67ac66d0c7f77d26a47357c592a") + .setDownloadChecksum("d9f23fd8687d6743f5c281c33539fb16f163304795039959b8caf159e6d62822") + .setSize(147004) + .setKind("font") + .setType("asset-archive") + .build()); + + ArrayMap<String, DownloadContent> content = new ArrayMap<>(); + for (DownloadContent currentContent : initialList) { + content.put(currentContent.getId(), currentContent); + } + return content; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentBuilder.java b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentBuilder.java new file mode 100644 index 000000000..243e2d4eb --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentBuilder.java @@ -0,0 +1,238 @@ +/* -*- 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 attachment = object.getJSONObject(KINTO_KEY_ATTACHMENT); + JSONObject original = attachment.getJSONObject(KINTO_KEY_ORIGINAL); + + setFilename(original.getString(KINTO_KEY_FILENAME)); + setChecksum(original.getString(KINTO_KEY_HASH)); + setSize(original.getLong(KINTO_KEY_SIZE)); + + 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; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentCatalog.java b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentCatalog.java new file mode 100644 index 000000000..43ba4e82e --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentCatalog.java @@ -0,0 +1,303 @@ +/* -*- 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.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 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 = new ArrayMap<>(); + this.file = file; + } + + 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> filteredContent = new ArrayList<>(); + + for (DownloadContent currentContent : content.values()) { + if (currentContent.isStateIn(filterStates)) { + filteredContent.add(currentContent); + } + } + + 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 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(); + + 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 lastModified; + } + + public synchronized void scheduleDownload(DownloadContent content) { + content.setState(DownloadContent.STATE_SCHEDULED); + hasCatalogChanged = true; + } + + public synchronized void markAsDownloaded(DownloadContent content) { + content.setState(DownloadContent.STATE_DOWNLOADED); + content.resetFailures(); + hasCatalogChanged = true; + } + + public synchronized void markAsPermanentlyFailed(DownloadContent content) { + content.setState(DownloadContent.STATE_FAILED); + 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; + } + } + + public void persistChanges() { + new Thread(LOGTAG + "-Persist") { + public void run() { + writeToDisk(); + } + }.start(); + } + + private void startLoadFromDisk() { + new Thread(LOGTAG + "-Load") { + public void run() { + loadFromDisk(); + } + }.start(); + } + + private void awaitLoadingCatalogLocked() { + while (!hasLoadedCatalog) { + try { + Log.v(LOGTAG, "Waiting for catalog to be loaded"); + + wait(); + } catch (InterruptedException e) { + // Ignore + } + } + } + + protected synchronized boolean hasCatalogChanged() { + return hasCatalogChanged; + } + + protected synchronized void loadFromDisk() { + Log.d(LOGTAG, "Loading from disk"); + + if (hasLoadedCatalog) { + return; + } + + 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++) { + 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"); + 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: + loadedContent = DownloadContentBootstrap.createInitialDownloadContentList(); + hasCatalogChanged = true; // Indicate that we want to persist the new catalog + } catch (NullPointerException e) { + // Bad content can produce an NPE in JSON code -- bug 1300139 + Log.w(LOGTAG, "Unable to parse catalog JSON. Re-creating catalog.", e); + // Catalog seems to be broken. Re-create catalog: + 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(loadedContent); + + notifyAll(); + + Log.d(LOGTAG, "Loaded " + content.size() + " elements"); + } + + 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; + } + + Log.d(LOGTAG, "Writing to disk"); + + FileOutputStream outputStream = null; + + synchronized (file) { + try { + outputStream = file.startWrite(); + + JSONArray array = new JSONArray(); + 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); + + hasCatalogChanged = false; + } catch (UnsupportedEncodingException e) { + AssertionError error = new AssertionError("Should not happen: This device does not speak UTF-8"); + error.initCause(e); + throw error; + } catch (IOException | JSONException e) { + Log.e(LOGTAG, "IOException during writing catalog", e); + + if (outputStream != null) { + file.failWrite(outputStream); + } + } + } + } +} |