blob: b1de88b205960e66b46fe49decbe20703b68bc65 [file] [log] [blame]
/*
* 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 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.monitor.DownloadProgressMonitor;
import com.google.android.libraries.mobiledatadownload.monitor.NetworkUsageMonitor;
import com.google.common.base.Optional;
import com.google.common.base.Supplier;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.FluentFuture;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
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.
*
* <p>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.
*
* <p>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<FileDownloader> fileDownloaderSupplier;
private final SynchronousFileStorage fileStorage;
private final NetworkUsageMonitor networkUsageMonitor;
private final Optional<DownloadProgressMonitor> 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: currently we assume that this map will only be accessed through the
// SequentialControlExecutor, so we don't need synchronization here.
@VisibleForTesting
final HashMap<Uri, ListenableFuture<Void>> fileUriToDownloadFutureMap = new HashMap<>();
@Inject
public MddFileDownloader(
@ApplicationContext Context context,
Supplier<FileDownloader> fileDownloaderSupplier,
SynchronousFileStorage fileStorage,
NetworkUsageMonitor networkUsageMonitor,
Optional<DownloadProgressMonitor> 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;
}
/**
* Start downloading the file.
*
* @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 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<Void> startDownloading(
GroupKey groupKey,
int fileGroupVersionNumber,
long buildId,
Uri fileUri,
String urlToDownload,
int fileSize,
@Nullable DownloadConditions downloadConditions,
DownloaderCallback callback,
int trafficTag,
List<ExtraHttpHeader> extraHttpHeaders) {
if (fileUriToDownloadFutureMap.containsKey(fileUri)) {
return fileUriToDownloadFutureMap.get(fileUri);
}
return addCallbackAndRegister(
fileUri,
callback,
startDownloadingInternal(
groupKey,
fileGroupVersionNumber,
buildId,
fileUri,
urlToDownload,
fileSize,
downloadConditions,
trafficTag,
extraHttpHeaders));
}
/**
* Adds Callback to given Future and Registers future in in-progress cache.
*
* <p>Contains shared logic of connecting {@code callback} to {@code downloadOrCopyFuture} and
* registers future in the internal in-progress cache. This cache allows similar download/copy
* requests to be deduped instead of being performed twice.
*
* <p>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 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 downloadOrCopyFuture a ListenableFuture that will perform the download/copy
* @return a ListenableFuture that calls the correct callback after {@code downloadOrCopyFuture
* completes}
*/
private ListenableFuture<Void> addCallbackAndRegister(
Uri fileUri, DownloaderCallback callback, ListenableFuture<Void> downloadOrCopyFuture) {
// Use transform & catching to ensure that we correctly chain everything.
FluentFuture<Void> transformedFuture =
FluentFuture.from(downloadOrCopyFuture)
.transformAsync(
voidArg -> callback.onDownloadComplete(fileUri),
sequentialControlExecutor /*Run callbacks on @SequentialControlExecutor*/)
.catchingAsync(
DownloadException.class,
e ->
Futures.transformAsync(
callback.onDownloadFailed(e),
voidArg -> {
throw e;
},
sequentialControlExecutor),
sequentialControlExecutor /*Run callbacks on @SequentialControlExecutor*/);
fileUriToDownloadFutureMap.put(fileUri, transformedFuture);
// We want to remove the transformedFuture 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(
() -> fileUriToDownloadFutureMap.remove(fileUri), sequentialControlExecutor);
return transformedFuture;
}
private ListenableFuture<Void> startDownloadingInternal(
GroupKey groupKey,
int fileGroupVersionNumber,
long buildId,
Uri fileUri,
String urlToDownload,
int fileSize,
@Nullable DownloadConditions downloadConditions,
int trafficTag,
List<ExtraHttpHeader> extraHttpHeaders) {
if (urlToDownload.startsWith("http")
&& flags.downloaderEnforceHttps()
&& !urlToDownload.startsWith("https")) {
LogUtil.e("%s: File url = %s is not secure", TAG, urlToDownload);
return Futures.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, 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 Futures.immediateFailedFuture(e);
}
if (flags.logNetworkStats()) {
networkUsageMonitor.monitorUri(
fileUri, groupKey, buildId, 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<Pair<String, String>> 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());
}
/**
* Start Copying a file to internal storage
*
* @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<Void> startCopying(
Uri fileUri,
String urlToDownload,
int fileSize,
@Nullable DownloadConditions downloadConditions,
DownloaderCallback downloaderCallback,
FileSource inlineFileSource) {
if (fileUriToDownloadFutureMap.containsKey(fileUri)) {
return fileUriToDownloadFutureMap.get(fileUri);
}
return addCallbackAndRegister(
fileUri,
downloaderCallback,
startCopyingInternal(
fileUri, urlToDownload, fileSize, downloadConditions, inlineFileSource));
}
private ListenableFuture<Void> startCopyingInternal(
Uri fileUri,
String urlToCopy,
int fileSize,
@Nullable DownloadConditions downloadConditions,
FileSource inlineFileSource) {
try {
checkStorageConstraints(context, fileSize, 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 Futures.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 fileUri - the File Uri of the file to stop downloading.
*/
public void stopDownloading(Uri fileUri) {
ListenableFuture<Void> pendingDownloadFuture = fileUriToDownloadFutureMap.get(fileUri);
if (pendingDownloadFuture != null) {
LogUtil.d("%s: Cancel download file %s", TAG, fileUri);
fileUriToDownloadFutureMap.remove(fileUri);
pendingDownloadFuture.cancel(true);
} else {
LogUtil.w("%s: stopDownloading on non-existent download", TAG);
}
}
/**
* Checks if storage constraints are enabled and if so, performs storage check.
*
* <p>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}.
*
* <p>If low storage enforcement is not enabled, this method is a no-op.
*
* <p>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
*/
public static void checkStorageConstraints(
Context context,
long bytesNeeded,
@Nullable DownloadConditions downloadConditions,
Flags flags)
throws DownloadException {
if (flags.enforceLowStorageBehavior()
&& !shouldDownload(context, 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.
*
* <p>This is in parity with what the DownloadApi does- <internal>
*/
private static boolean shouldDownload(
Context context,
long bytesNeeded,
@Nullable DownloadConditions downloadConditions,
Flags flags) {
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;
}
/** 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<Void> onDownloadComplete(Uri fileUri);
/** Called on download failed. */
ListenableFuture<Void> onDownloadFailed(DownloadException exception);
}
}