blob: 897261d9e293fc84d50b92fcd8215d686504ca93 [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 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 android.net.Uri;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
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.Flags;
import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
import com.google.android.libraries.mobiledatadownload.file.openers.ReadStreamOpener;
import com.google.android.libraries.mobiledatadownload.file.openers.RecursiveSizeOpener;
import com.google.android.libraries.mobiledatadownload.file.openers.WriteStreamOpener;
import com.google.android.libraries.mobiledatadownload.file.transforms.TransformProtos;
import com.google.android.libraries.mobiledatadownload.internal.SharedFilesMetadata;
import com.google.android.libraries.mobiledatadownload.internal.downloader.MddFileDownloader.DownloaderCallback;
import com.google.android.libraries.mobiledatadownload.internal.logging.EventLogger;
import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
import com.google.android.libraries.mobiledatadownload.internal.util.FileGroupUtil;
import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFluentFuture;
import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFutures;
import com.google.common.io.ByteStreams;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.mobiledatadownload.internal.MetadataProto.DataFile;
import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal.AllowedReaders;
import com.google.mobiledatadownload.internal.MetadataProto.FileStatus;
import com.google.mobiledatadownload.internal.MetadataProto.GroupKey;
import com.google.mobiledatadownload.internal.MetadataProto.NewFileKey;
import com.google.mobiledatadownload.internal.MetadataProto.SharedFile;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.concurrent.Executor;
/**
* Impl for {@link DownloaderCallback}, that is called by the file downloader on download complete
* or failed events
*/
public class DownloaderCallbackImpl implements DownloaderCallback {
private static final String TAG = "DownloaderCallbackImpl";
private final SharedFilesMetadata sharedFilesMetadata;
private final SynchronousFileStorage fileStorage;
private final DataFile dataFile;
private final AllowedReaders allowedReaders;
private final String checksum;
private final EventLogger eventLogger;
private final GroupKey groupKey;
private final int fileGroupVersionNumber;
private final long buildId;
private final String variantId;
private final Flags flags;
private final Executor sequentialControlExecutor;
public DownloaderCallbackImpl(
SharedFilesMetadata sharedFilesMetadata,
SynchronousFileStorage fileStorage,
DataFile dataFile,
AllowedReaders allowedReaders,
EventLogger eventLogger,
GroupKey groupKey,
int fileGroupVersionNumber,
long buildId,
String variantId,
Flags flags,
Executor sequentialControlExecutor) {
this.sharedFilesMetadata = sharedFilesMetadata;
this.fileStorage = fileStorage;
this.dataFile = dataFile;
this.allowedReaders = allowedReaders;
checksum = FileGroupUtil.getFileChecksum(dataFile);
this.eventLogger = eventLogger;
this.groupKey = groupKey;
this.fileGroupVersionNumber = fileGroupVersionNumber;
this.buildId = buildId;
this.variantId = variantId;
this.flags = flags;
this.sequentialControlExecutor = sequentialControlExecutor;
}
@Override
public ListenableFuture<Void> onDownloadComplete(Uri fileUri) {
LogUtil.d("%s: Successfully downloaded file %s", TAG, checksum);
// Use DownloadedFileChecksum to verify downloaded file integrity if the file has Download
// Transforms
String downloadedFileChecksum =
dataFile.hasDownloadTransforms()
? dataFile.getDownloadedFileChecksum()
: dataFile.getChecksum();
try {
FileValidator.validateDownloadedFile(fileStorage, dataFile, fileUri, downloadedFileChecksum);
if (dataFile.hasDownloadTransforms()) {
handleDownloadTransform(fileUri);
}
} catch (DownloadException exception) {
if (exception
.getDownloadResultCode()
.equals(DownloadResultCode.DOWNLOADED_FILE_CHECKSUM_MISMATCH_ERROR)) {
// File was downloaded successfully, but failed checksum mismatch error. Attempt to delete
// the file, then fail with the given exception.
return PropagatedFluentFuture.from(
maybeDeleteFileOnChecksumMismatch(
sharedFilesMetadata,
dataFile,
allowedReaders,
fileStorage,
fileUri,
checksum,
eventLogger,
flags,
sequentialControlExecutor))
.catchingAsync(
IOException.class,
ioException -> {
// Delete on checksum failed, add it as a suppressed exception if supported (API
// level 19 or higher).
if (VERSION.SDK_INT >= VERSION_CODES.KITKAT) {
exception.addSuppressed(ioException);
}
return immediateVoidFuture();
},
sequentialControlExecutor)
.transformAsync(unused -> immediateFailedFuture(exception), sequentialControlExecutor);
}
return immediateFailedFuture(exception);
}
return updateFileStatus(
FileStatus.DOWNLOAD_COMPLETE,
dataFile,
allowedReaders,
sharedFilesMetadata,
sequentialControlExecutor);
}
@Override
public ListenableFuture<Void> onDownloadFailed(DownloadException exception) {
LogUtil.d("%s: Failed to download file %s", TAG, checksum);
if (exception
.getDownloadResultCode()
.equals(DownloadResultCode.DOWNLOADED_FILE_CHECKSUM_MISMATCH_ERROR)) {
return updateFileStatus(
FileStatus.CORRUPTED,
dataFile,
allowedReaders,
sharedFilesMetadata,
sequentialControlExecutor);
}
return updateFileStatus(
FileStatus.DOWNLOAD_FAILED,
dataFile,
allowedReaders,
sharedFilesMetadata,
sequentialControlExecutor);
}
private void handleDownloadTransform(Uri downloadedFileUri) throws DownloadException {
if (!dataFile.hasDownloadTransforms()) {
return;
}
Uri finalFileUri = FileNameUtil.getFinalFileUriWithTempDownloadedFile(downloadedFileUri);
if (FileGroupUtil.hasZipDownloadTransform(dataFile)) {
applyZipDownloadTransforms(
eventLogger,
fileStorage,
downloadedFileUri,
finalFileUri,
groupKey,
fileGroupVersionNumber,
buildId,
variantId,
dataFile.getFileId());
} else {
handleNonZipDownloadTransform(downloadedFileUri, finalFileUri);
}
}
private void handleNonZipDownloadTransform(Uri downloadedFileUri, Uri finalFileUri)
throws DownloadException {
Uri downloadFileUriWithTransform;
try {
downloadFileUriWithTransform =
downloadedFileUri
.buildUpon()
.encodedFragment(TransformProtos.toEncodedFragment(dataFile.getDownloadTransforms()))
.build();
} catch (IllegalArgumentException e) {
LogUtil.e(e, "%s: Exception while trying to serialize download transform", TAG);
throw DownloadException.builder()
.setDownloadResultCode(DownloadResultCode.UNABLE_TO_SERIALIZE_DOWNLOAD_TRANSFORM_ERROR)
.setCause(e)
.build();
}
applyDownloadTransforms(
eventLogger,
fileStorage,
downloadFileUriWithTransform,
finalFileUri,
groupKey,
fileGroupVersionNumber,
buildId,
variantId,
dataFile);
// Verify original checksum if provided.
if (dataFile.getChecksumType() != DataFile.ChecksumType.NONE
&& !FileValidator.verifyChecksum(fileStorage, finalFileUri, checksum)) {
LogUtil.e("%s: Final file checksum verification failed. %s.", TAG, finalFileUri);
throw DownloadException.builder()
.setDownloadResultCode(DownloadResultCode.FINAL_FILE_CHECKSUM_MISMATCH_ERROR)
.build();
}
}
@VisibleForTesting
static void applyDownloadTransforms(
EventLogger eventLogger,
SynchronousFileStorage fileStorage,
Uri source,
Uri target,
GroupKey groupKey,
int fileGroupVersionNumber,
long buildId,
String variantId,
DataFile dataFile)
throws DownloadException {
try (InputStream in = fileStorage.open(source, ReadStreamOpener.create());
OutputStream out = fileStorage.open(target, WriteStreamOpener.create())) {
ByteStreams.copy(in, out);
} catch (IOException ioe) {
LogUtil.e(ioe, "%s: Failed to apply download transform for file %s.", TAG, source);
throw DownloadException.builder()
.setDownloadResultCode(DownloadResultCode.DOWNLOAD_TRANSFORM_IO_ERROR)
.setCause(ioe)
.build();
}
try {
if (FileGroupUtil.hasCompressDownloadTransform(dataFile)) {
long fullFileSize = fileStorage.fileSize(target);
long downloadedFileSize = fileStorage.fileSize(source);
if (fullFileSize > downloadedFileSize) {
Void fileGroupStats = null;
eventLogger.logMddNetworkSavings(
fileGroupStats,
0,
fullFileSize,
downloadedFileSize,
dataFile.getFileId(),
/* deltaIndex = */ 0);
}
}
fileStorage.deleteFile(source);
} catch (IOException ioe) {
// Ignore if fails to log file size or delete the temp compress file, as it will eventually
// be garbage collected.
LogUtil.d(ioe, "%s: Failed to get file size or delete compress file %s.", TAG, source);
}
}
@VisibleForTesting
static void applyZipDownloadTransforms(
EventLogger eventLogger,
SynchronousFileStorage fileStorage,
Uri source,
Uri target,
GroupKey groupKey,
int fileGroupVersionNumber,
long buildId,
String variantId,
String fileId)
throws DownloadException {
try {
fileStorage.open(source, ZipFolderOpener.create(target));
} catch (IOException ioe) {
LogUtil.e(ioe, "%s: Failed to apply zip download transform for file %s.", TAG, source);
throw DownloadException.builder()
.setDownloadResultCode(DownloadResultCode.DOWNLOAD_TRANSFORM_IO_ERROR)
.setCause(ioe)
.build();
}
try {
Void fileGroupStats = null;
eventLogger.logMddNetworkSavings(
fileGroupStats,
0,
getFileOrDirectorySize(fileStorage, target),
fileStorage.fileSize(source),
fileId,
0);
// Delete the zip file only if unzip successfully to avoid re-download
fileStorage.deleteFile(source);
} catch (IOException ioe) {
// Ignore if fails to log file size or delete the temp zip file, as it will eventually be
// garbage collected.
LogUtil.d(ioe, "%s: Failed to get file size or delete zip file %s.", TAG, source);
}
}
private static long getFileOrDirectorySize(SynchronousFileStorage fileStorage, Uri uri)
throws IOException {
return fileStorage.open(uri, RecursiveSizeOpener.create());
}
/** Get {@link SharedFile} or fail with {@link DownloadException}. */
private static ListenableFuture<SharedFile> getSharedFileOrFail(
SharedFilesMetadata sharedFilesMetadata,
NewFileKey newFileKey,
Executor sequentialControlExecutor) {
return PropagatedFutures.transformAsync(
sharedFilesMetadata.read(newFileKey),
sharedFile -> {
// Cannot find the file metadata, fail to update the file status.
if (sharedFile == null) {
// TODO(b/131166925): MDD dump should not use lite proto toString.
LogUtil.e("%s: Shared file not found, newFileKey = %s", TAG, newFileKey);
return immediateFailedFuture(
DownloadException.builder()
.setDownloadResultCode(DownloadResultCode.SHARED_FILE_NOT_FOUND_ERROR)
.build());
}
return immediateFuture(sharedFile);
},
sequentialControlExecutor);
}
/**
* Maybe delete on-device file after a completed download when a checksum mismatch occurs.
*
* <p>When a checksum mismatch occurs after a completed download, it's possible that the data has
* been corrupted on-disk. In this event, we should delete the on-disk file so it can be
* redownloaded again in a non-corrupted state.
*
* <p>However, it's also possible that a bad config was sent with a wrong checksum. In this event,
* the on-disk file may not be corrupted, so deleting it could lead to an increase in network
* bandwidth usage.
*
* <p>In order to balance the two cases, MDD will start to delete the on-disk file, but after a
* certain number of retries, this deletion will be skipped to prevent unnecessary network
* bandwidth usage.
*
* <p>This future may return a failed future with an IOException if attempting to delete the file
* fails.
*/
static ListenableFuture<Void> maybeDeleteFileOnChecksumMismatch(
SharedFilesMetadata sharedFilesMetadata,
DataFile dataFile,
AllowedReaders allowedReaders,
SynchronousFileStorage fileStorage,
Uri fileUri,
String checksum,
EventLogger eventLogger,
Flags flags,
Executor sequentialControlExecutor) {
NewFileKey newFileKey = SharedFilesMetadata.createKeyFromDataFile(dataFile, allowedReaders);
return PropagatedFluentFuture.from(
getSharedFileOrFail(sharedFilesMetadata, newFileKey, sequentialControlExecutor))
.transformAsync(
sharedFile -> {
if (sharedFile.getChecksumMismatchRetryDownloadCount()
>= flags.downloaderMaxRetryOnChecksumMismatchCount()) {
LogUtil.d(
"%s: Checksum mismatch detected but the has already reached retry limit!"
+ " Skipping removal for file %s",
TAG, checksum);
eventLogger.logEventSampled(0);
} else {
LogUtil.d(
"%s: Removing file and marking as corrupted due to checksum mismatch", TAG);
try {
fileStorage.deleteFile(fileUri);
} catch (IOException e) {
// Deleting the corrupted file is best effort, the next time MDD attempts to
// download, we will try again to delete the file. For now, just log this error.
LogUtil.e(e, "%s: Failed to remove corrupted file %s", TAG, checksum);
return immediateFailedFuture(e);
}
}
return immediateVoidFuture();
},
sequentialControlExecutor);
}
/**
* Find the file metadata and update the file status. Throws {@link DownloadException} if the file
* status failed to be updated.
*/
static ListenableFuture<Void> updateFileStatus(
FileStatus fileStatus,
DataFile dataFile,
AllowedReaders allowedReaders,
SharedFilesMetadata sharedFilesMetadata,
Executor sequentialControlExecutor) {
NewFileKey newFileKey = SharedFilesMetadata.createKeyFromDataFile(dataFile, allowedReaders);
return PropagatedFluentFuture.from(
getSharedFileOrFail(sharedFilesMetadata, newFileKey, sequentialControlExecutor))
.transformAsync(
sharedFile -> {
SharedFile.Builder sharedFileBuilder =
sharedFile.toBuilder().setFileStatus(fileStatus);
if (fileStatus.equals(FileStatus.CORRUPTED)) {
// Corrupted state indicates a checksum mismatch failure, so increment the retry
// download count.
sharedFileBuilder.setChecksumMismatchRetryDownloadCount(
sharedFile.getChecksumMismatchRetryDownloadCount() + 1);
}
return sharedFilesMetadata.write(newFileKey, sharedFileBuilder.build());
},
sequentialControlExecutor)
.transformAsync(
writeSuccess -> {
if (!writeSuccess) {
// TODO(b/131166925): MDD dump should not use lite proto toString.
LogUtil.e(
"%s: Unable to write back download info for file entry with %s",
TAG, newFileKey);
return immediateFailedFuture(
DownloadException.builder()
.setDownloadResultCode(DownloadResultCode.UNABLE_TO_UPDATE_FILE_STATE_ERROR)
.build());
}
return immediateVoidFuture();
},
sequentialControlExecutor);
}
}