/* * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.android.libraries.mobiledatadownload.internal.downloader; import static com.google.common.util.concurrent.Futures.immediateFailedFuture; import static com.google.common.util.concurrent.Futures.immediateFuture; import static com.google.common.util.concurrent.Futures.immediateVoidFuture; import static java.lang.Math.min; import android.content.Context; import android.net.Uri; import android.os.StatFs; import android.util.Pair; import androidx.annotation.VisibleForTesting; import com.google.android.libraries.mobiledatadownload.DownloadException; import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode; import com.google.android.libraries.mobiledatadownload.FileSource; import com.google.android.libraries.mobiledatadownload.Flags; import com.google.android.libraries.mobiledatadownload.downloader.DownloadConstraints; import com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest; import com.google.android.libraries.mobiledatadownload.downloader.FileDownloader; import com.google.android.libraries.mobiledatadownload.downloader.InlineDownloadParams; import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage; import com.google.android.libraries.mobiledatadownload.internal.ApplicationContext; import com.google.android.libraries.mobiledatadownload.internal.annotations.SequentialControlExecutor; import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil; import com.google.android.libraries.mobiledatadownload.internal.logging.LoggingStateStore; import com.google.android.libraries.mobiledatadownload.internal.util.DownloadFutureMap; import com.google.android.libraries.mobiledatadownload.internal.util.FileGroupUtil; import com.google.android.libraries.mobiledatadownload.monitor.DownloadProgressMonitor; import com.google.android.libraries.mobiledatadownload.monitor.NetworkUsageMonitor; import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFluentFuture; import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFutures; import com.google.common.base.Optional; import com.google.common.base.Supplier; import com.google.common.collect.ImmutableList; import com.google.common.util.concurrent.AsyncFunction; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFutureTask; import com.google.mobiledatadownload.internal.MetadataProto.DownloadConditions; import com.google.mobiledatadownload.internal.MetadataProto.DownloadConditions.DeviceNetworkPolicy; import com.google.mobiledatadownload.internal.MetadataProto.ExtraHttpHeader; import com.google.mobiledatadownload.internal.MetadataProto.GroupKey; import java.io.IOException; import java.util.HashMap; import java.util.List; import java.util.concurrent.Executor; import javax.annotation.Nullable; import javax.inject.Inject; /** * Responsible for downloading files in MDD. * *

Provides methods to start and stop downloading a file. The stop method can be called if the * file is no longer needed, or the file was already downloaded to the device. * *

This class supports both standard downloads (over https) or inline files (from a ByteString), * using {@link #startDownloading} and {@link #startCopying}, respectively. */ // TODO(b/129497867): Add tracking for on-going download to dedup download request from // FileDownloader. public class MddFileDownloader { private static final String TAG = "MddFileDownloader"; // These should only be accessed through the getters and never directly. private final Context context; private final Supplier fileDownloaderSupplier; private final SynchronousFileStorage fileStorage; private final NetworkUsageMonitor networkUsageMonitor; private final Optional downloadMonitorOptional; private final LoggingStateStore loggingStateStore; private final Executor sequentialControlExecutor; private final Flags flags; // Cache for all on-going downloads. This will be used to de-dup download requests. // NOTE: all operations are internally sequenced through an ExecutionSequencer. // NOTE: this map and fileUriToDownloadFutureMap are mutually exclusive and the use of // one or the other is based on an MDD feature flag (enableFileDownloadDedupByFileKey). Once the // flag is fully rolled out, this map will be used exclusively. private final DownloadFutureMap downloadOrCopyFutureMap; // Cache for all on-going downloads. This will be used to de-dup download requests. // NOTE: currently we assume that this map will only be accessed through the // SequentialControlExecutor, so we don't need synchronization here. // NOTE: this map and downloadOrCopyFutureMap are mutually exclusive and the use of // one or the other is based on an MDD feature flag (enableFileDownloadDedupByFileKey). Once the // flag is fully rolled out, this map will not be used. @VisibleForTesting final HashMap> fileUriToDownloadFutureMap = new HashMap<>(); @Inject public MddFileDownloader( @ApplicationContext Context context, Supplier fileDownloaderSupplier, SynchronousFileStorage fileStorage, NetworkUsageMonitor networkUsageMonitor, Optional downloadMonitor, LoggingStateStore loggingStateStore, @SequentialControlExecutor Executor sequentialControlExecutor, Flags flags) { this.context = context; this.fileDownloaderSupplier = fileDownloaderSupplier; this.fileStorage = fileStorage; this.networkUsageMonitor = networkUsageMonitor; this.downloadMonitorOptional = downloadMonitor; this.loggingStateStore = loggingStateStore; this.sequentialControlExecutor = sequentialControlExecutor; this.flags = flags; this.downloadOrCopyFutureMap = DownloadFutureMap.create(sequentialControlExecutor); } /** * Start downloading the file. * * @param fileKey key that identifies the shared file to download. * @param groupKey GroupKey that contains the file to download. * @param fileGroupVersionNumber version number of the group that contains the file to download. * @param buildId build id of the group that contains the file to download. * @param variantId variant id of the group that contains the file to download. * @param fileUri - the File Uri to download the file at. * @param urlToDownload - The url of the file to download. * @param fileSize - the expected size of the file to download. * @param downloadConditions - conditions under which this file should be downloaded. * @param callback - callback called when the download either completes or fails. * @param trafficTag - Tag for the network traffic to download this dataFile. * @param extraHttpHeaders - Extra Headers for this request. * @return - ListenableFuture representing the download result of a file. */ public ListenableFuture startDownloading( String fileKey, GroupKey groupKey, int fileGroupVersionNumber, long buildId, String variantId, Uri fileUri, String urlToDownload, int fileSize, @Nullable DownloadConditions downloadConditions, DownloaderCallback callback, int trafficTag, List extraHttpHeaders) { return PropagatedFutures.transformAsync( getInProgressFuture(fileKey, fileUri), inProgressFuture -> { if (inProgressFuture.isPresent()) { return inProgressFuture.get(); } return addCallbackAndRegister( fileKey, fileUri, callback, unused -> startDownloadingInternal( groupKey, fileGroupVersionNumber, buildId, variantId, fileUri, urlToDownload, fileSize, downloadConditions, trafficTag, extraHttpHeaders)); }, sequentialControlExecutor); } /** * Adds Callback to given Future and Registers future in in-progress cache. * *

Contains shared logic of connecting {@code callback} to {@code downloadOrCopyFunction} and * registers future in the internal in-progress cache. This cache allows similar download/copy * requests to be deduped instead of being performed twice. * *

NOTE: this method assumes the cache has already been checked for an in-progress operation * and no in-progress operation exists for {@code fileUri}. * * @param fileKey key that identifies the shared file. * @param fileUri the destination of the download/copy (used as Key in in-progress cache) * @param callback the callback that should be run after the given download/copy future * @param downloadOrCopyFunction an AsyncFunction that will perform the download/copy * @return a ListenableFuture that calls the correct callback after {@code downloadOrCopyFuture * completes} */ private ListenableFuture addCallbackAndRegister( String fileKey, Uri fileUri, DownloaderCallback callback, AsyncFunction downloadOrCopyFunction) { // Use ListenableFutureTask to create a future without starting it. This ensures we can // successfully add our future to download/copy before the operation starts. ListenableFutureTask startTask = ListenableFutureTask.create(() -> null); // Use transform & catching to ensure that we correctly chain everything. PropagatedFluentFuture downloadOrCopyFuture = PropagatedFluentFuture.from(startTask) .transformAsync(downloadOrCopyFunction, sequentialControlExecutor) .transformAsync( voidArg -> callback.onDownloadComplete(fileUri), sequentialControlExecutor /*Run callbacks on @SequentialControlExecutor*/) .catchingAsync( Exception.class, e -> // Rethrow exception so the failure is passed back up the future chain. PropagatedFutures.transformAsync( callback.onDownloadFailed(asDownloadException(e)), voidArg -> { throw e; }, sequentialControlExecutor), sequentialControlExecutor /*Run callbacks on @SequentialControlExecutor*/); // Add this future to the future map, then start startTask to unblock download/copy. The order // ensures that the download/copy happens only if we were able to add the future to the map. PropagatedFluentFuture transformedFuture = PropagatedFluentFuture.from(addFutureToMap(downloadOrCopyFuture, fileKey, fileUri)) .transformAsync( unused -> { startTask.run(); return downloadOrCopyFuture; }, sequentialControlExecutor); // We want to remove the future from the cache when the transformedFuture finishes. // However there may be a race condition and transformedFuture may finish before we put it into // the cache. // To prevent this race condition, we add a callback to transformedFuture to make sure the // removal happens after the putting it in the map. // A transform would not work since we want to run the removal even when the transform failed. transformedFuture.addListener( () -> { ListenableFuture unused = removeFutureFromMap(fileKey, fileUri); }, sequentialControlExecutor); return transformedFuture; } private ListenableFuture addFutureToMap( ListenableFuture downloadOrCopyFuture, String fileKey, Uri fileUri) { if (!flags.enableFileDownloadDedupByFileKey()) { fileUriToDownloadFutureMap.put(fileUri, downloadOrCopyFuture); return immediateVoidFuture(); } else { return downloadOrCopyFutureMap.add(fileKey, downloadOrCopyFuture); } } private ListenableFuture removeFutureFromMap(String fileKey, Uri fileUri) { if (!flags.enableFileDownloadDedupByFileKey()) { // Return the removed future if it exists, otherwise return immediately (Extra check added to // satisfy nullness checker). ListenableFuture removedFuture = fileUriToDownloadFutureMap.remove(fileUri); if (removedFuture != null) { return removedFuture; } return immediateVoidFuture(); } else { return downloadOrCopyFutureMap.remove(fileKey); } } private ListenableFuture startDownloadingInternal( GroupKey groupKey, int fileGroupVersionNumber, long buildId, String variantId, Uri fileUri, String urlToDownload, int fileSize, @Nullable DownloadConditions downloadConditions, int trafficTag, List extraHttpHeaders) { if (urlToDownload.startsWith("http") && flags.downloaderEnforceHttps() && !urlToDownload.startsWith("https")) { LogUtil.e("%s: File url = %s is not secure", TAG, urlToDownload); return immediateFailedFuture( DownloadException.builder() .setDownloadResultCode(DownloadResultCode.INSECURE_URL_ERROR) .build()); } long currentFileSize = 0; try { currentFileSize = fileStorage.fileSize(fileUri); } catch (IOException e) { // Proceed with 0 as the current file size. It is only used for deciding whether we should // download the file or not. } try { checkStorageConstraints( context, urlToDownload, fileSize - currentFileSize, downloadConditions, flags); } catch (DownloadException e) { // Wrap exception in future to break future chain. LogUtil.e("%s: Not enough space to download file %s", TAG, urlToDownload); return immediateFailedFuture(e); } if (flags.logNetworkStats()) { networkUsageMonitor.monitorUri( fileUri, groupKey, buildId, variantId, fileGroupVersionNumber, loggingStateStore); } else { LogUtil.w("%s: NetworkUsageMonitor is disabled", TAG); } if (downloadMonitorOptional.isPresent()) { downloadMonitorOptional.get().monitorUri(fileUri, groupKey.getGroupName()); } DownloadRequest.Builder downloadRequestBuilder = DownloadRequest.newBuilder().setFileUri(fileUri).setUrlToDownload(urlToDownload); // TODO: consider to do this conversion upstream and we can pass in the // DownloadConstraints. if (downloadConditions != null && downloadConditions.getDeviceNetworkPolicy() == DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK) { downloadRequestBuilder.setDownloadConstraints(DownloadConstraints.NETWORK_CONNECTED); } else { downloadRequestBuilder.setDownloadConstraints(DownloadConstraints.NETWORK_UNMETERED); } if (trafficTag > 0) { downloadRequestBuilder.setTrafficTag(trafficTag); } ImmutableList.Builder> headerBuilder = ImmutableList.builder(); for (ExtraHttpHeader header : extraHttpHeaders) { headerBuilder.add(Pair.create(header.getKey(), header.getValue())); } downloadRequestBuilder.setExtraHttpHeaders(headerBuilder.build()); return fileDownloaderSupplier.get().startDownloading(downloadRequestBuilder.build()); } /** * Gets an in-progress future (if it exists), otherwise returns absent. * *

This method allows easier deduplication of file downloads/copies, by allowing callers to * query against the internal download future map. This method is assumed to be called when a * SharedFile state is DOWNLOAD_IN_PROGRESS. * * @param fileKey key that identifies the shared file. * @param fileUri - the File Uri to download the file at. * @return - ListenableFuture representing an in-progress download/copy for the given file. */ public ListenableFuture>> getInProgressFuture( String fileKey, Uri fileUri) { if (!flags.enableFileDownloadDedupByFileKey()) { return immediateFuture(Optional.fromNullable(fileUriToDownloadFutureMap.get(fileUri))); } else { return downloadOrCopyFutureMap.get(fileKey); } } /** * Start Copying a file to internal storage * * @param fileKey key that identifies the shared file to copy. * @param fileUri the File Uri where content should be copied. * @param urlToDownload the url to copy, should be inlinefile: scheme. * @param fileSize the size of the file to copy. * @param downloadConditions conditions under which this file should be copied. * @param downloaderCallback callback called when the copy either completes or fails. * @param inlineFileSource Source of file content to copy. * @return ListenableFuture representing the result of a file copy. */ public ListenableFuture startCopying( String fileKey, Uri fileUri, String urlToDownload, int fileSize, @Nullable DownloadConditions downloadConditions, DownloaderCallback downloaderCallback, FileSource inlineFileSource) { return PropagatedFutures.transformAsync( getInProgressFuture(fileKey, fileUri), inProgressFuture -> { if (inProgressFuture.isPresent()) { return inProgressFuture.get(); } return addCallbackAndRegister( fileKey, fileUri, downloaderCallback, unused -> startCopyingInternal( fileUri, urlToDownload, fileSize, downloadConditions, inlineFileSource)); }, sequentialControlExecutor); } private ListenableFuture startCopyingInternal( Uri fileUri, String urlToCopy, int fileSize, @Nullable DownloadConditions downloadConditions, FileSource inlineFileSource) { int finalFileSize = fileSize; if (inlineFileSource.getKind().equals(FileSource.Kind.BYTESTRING)) { int sourceFileSize = inlineFileSource.byteString().size(); if (sourceFileSize != fileSize) { LogUtil.w( "%s: expected file size (%d) does not match source file size (%d) -- using source file" + " size for storage check; file: %s", TAG, fileSize, sourceFileSize, urlToCopy); finalFileSize = sourceFileSize; } } try { checkStorageConstraints(context, urlToCopy, finalFileSize, downloadConditions, flags); } catch (DownloadException e) { // Wrap exception in future to break future chain. LogUtil.e("%s: Not enough space to download file %s", TAG, urlToCopy); return immediateFailedFuture(e); } // TODO(b/177361344): Only monitor file if download listener is supported DownloadRequest downloadRequest = DownloadRequest.newBuilder() .setUrlToDownload(urlToCopy) .setFileUri(fileUri) .setInlineDownloadParamsOptional( InlineDownloadParams.newBuilder().setInlineFileContent(inlineFileSource).build()) .build(); // Use file download supplier to perform inline file download return fileDownloaderSupplier.get().startDownloading(downloadRequest); } /** * Stop downloading the file. * * @param fileKey - key that identifies the file to stop downloading. * @param fileUri - the File Uri of the file to stop downloading. */ public void stopDownloading(String fileKey, Uri fileUri) { ListenableFuture unused = PropagatedFutures.transformAsync( getInProgressFuture(fileKey, fileUri), inProgressFuture -> { if (inProgressFuture.isPresent()) { LogUtil.d("%s: Cancel download file %s", TAG, fileUri); inProgressFuture.get().cancel(/* mayInterruptIfRunning= */ true); return removeFutureFromMap(fileKey, fileUri); } else { LogUtil.w("%s: stopDownloading on non-existent download", TAG); return immediateVoidFuture(); } }, sequentialControlExecutor); } /** * Checks if storage constraints are enabled and if so, performs storage check. * *

If low storage enforcement is enabled, this method will check if a file with {@code * bytesNeeded} can be stored on disk without hitting the storage threshold defined in {@code * downloadConditions}. * *

If low storage enforcement is not enabled, this method is a no-op. * *

If {@code bytesNeeded} does hit the given storage threshold, a {@link DownloadException} * will be thrown with the {@code DownloadResultCode.LOW_DISK_ERROR} error code. * * @param context Context in which storage should be checked * @param bytesNeeded expected size of the file to store on disk * @param downloadConditions conditions that contain the type of storage threshold to check * @throws DownloadException when storing a file with the given size would hit the given storage * thresholds */ private static void checkStorageConstraints( Context context, String url, long bytesNeeded, @Nullable DownloadConditions downloadConditions, Flags flags) throws DownloadException { if (flags.enforceLowStorageBehavior() && !shouldDownload(context, url, bytesNeeded, downloadConditions, flags)) { throw DownloadException.builder() .setDownloadResultCode(DownloadResultCode.LOW_DISK_ERROR) .build(); } } /** * This calculates if the file should be downloaded. It checks that after download you have at * least a certain fraction of free space or an absolute minimum space still available. * *

This is in parity with what the DownloadApi does- */ private static boolean shouldDownload( Context context, String url, long bytesNeeded, @Nullable DownloadConditions downloadConditions, Flags flags) { // If we are using a placeholder (inline file + 0 byte size), bypass storage checks. if (FileGroupUtil.isInlineFile(url) && bytesNeeded == 0L) { return true; } StatFs stats = new StatFs(context.getFilesDir().getAbsolutePath()); long totalBytes = (long) stats.getBlockCount() * stats.getBlockSize(); long freeBytes = (long) stats.getAvailableBlocks() * stats.getBlockSize(); double remainingBytesAfterDownload = freeBytes - bytesNeeded; double minBytes = min(totalBytes * flags.fractionFreeSpaceAfterDownload(), flags.absFreeSpaceAfterDownload()); if (downloadConditions != null) { switch (downloadConditions.getDeviceStoragePolicy()) { case BLOCK_DOWNLOAD_LOWER_THRESHOLD: minBytes = min( totalBytes * flags.fractionFreeSpaceAfterDownload(), flags.absFreeSpaceAfterDownloadLowStorageAllowed()); break; case EXTREMELY_LOW_THRESHOLD: minBytes = min( totalBytes * flags.fractionFreeSpaceAfterDownload(), flags.absFreeSpaceAfterDownloadExtremelyLowStorageAllowed()); break; default: // fallthrough. } } return remainingBytesAfterDownload > minBytes; } /** * Wraps throwable as DownloadException if it isn't one already. * *

This method doesn't check the incoming throwable besides the type and defaults the download * result code to UNKNOWN_ERROR. */ private static DownloadException asDownloadException(Throwable t) { if (t instanceof DownloadException) { return (DownloadException) t; } return DownloadException.builder() .setCause(t) .setDownloadResultCode(DownloadResultCode.UNKNOWN_ERROR) .build(); } /** Interface called by the downloader when download either completes or fails. */ public static interface DownloaderCallback { /** Called on download complete. */ // TODO(b/123424546): Consider to drop fileUri. ListenableFuture onDownloadComplete(Uri fileUri); /** Called on download failed. */ ListenableFuture onDownloadFailed(DownloadException exception); } }