blob: 4201b1942f98ba6ade6bab63b600a21747e2b4a1 [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;
import static com.google.android.libraries.mobiledatadownload.tracing.TracePropagation.propagateAsyncCallable;
import static com.google.android.libraries.mobiledatadownload.tracing.TracePropagation.propagateAsyncFunction;
import static com.google.android.libraries.mobiledatadownload.tracing.TracePropagation.propagateCallable;
import android.accounts.Account;
import android.content.Context;
import android.net.Uri;
import android.text.TextUtils;
import android.util.Pair;
import androidx.annotation.VisibleForTesting;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode;
import com.google.android.libraries.mobiledatadownload.TaskScheduler.ConstraintOverrides;
import com.google.android.libraries.mobiledatadownload.TaskScheduler.NetworkState;
import com.google.android.libraries.mobiledatadownload.account.AccountUtil;
import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
import com.google.android.libraries.mobiledatadownload.foreground.NotificationUtil;
import com.google.android.libraries.mobiledatadownload.internal.MobileDataDownloadManager;
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.MddLiteConversionUtil;
import com.google.android.libraries.mobiledatadownload.internal.util.ProtoConversionUtil;
import com.google.android.libraries.mobiledatadownload.lite.Downloader;
import com.google.android.libraries.mobiledatadownload.monitor.DownloadProgressMonitor;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.util.concurrent.AsyncFunction;
import com.google.common.util.concurrent.ExecutionSequencer;
import com.google.common.util.concurrent.FluentFuture;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.mobiledatadownload.ClientConfigProto.ClientFile;
import com.google.mobiledatadownload.ClientConfigProto.ClientFileGroup;
import com.google.mobiledatadownload.DownloadConfigProto;
import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup;
import com.google.mobiledatadownload.internal.MetadataProto.DataFile;
import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal;
import com.google.mobiledatadownload.internal.MetadataProto.DownloadConditions;
import com.google.mobiledatadownload.internal.MetadataProto.GroupKey;
import com.google.protobuf.Any;
import com.google.protobuf.GeneratedMessageLite;
import com.google.protobuf.InvalidProtocolBufferException;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import javax.annotation.Nullable;
/**
* Default implementation for {@link
* com.google.android.libraries.mobiledatadownload.MobileDataDownload}.
*/
class MobileDataDownloadImpl implements MobileDataDownload {
private static final String TAG = "MobileDataDownload";
private static final long DUMP_DEBUG_INFO_TIMEOUT = 3;
private final Context context;
private final EventLogger eventLogger;
private final List<FileGroupPopulator> fileGroupPopulatorList;
private final Optional<TaskScheduler> taskSchedulerOptional;
private final MobileDataDownloadManager mobileDataDownloadManager;
private final SynchronousFileStorage fileStorage;
private final Flags flags;
private final Downloader singleFileDownloader;
// This executor will execute tasks sequentially.
private final Executor sequentialControlExecutor;
// ExecutionSequencer will execute a ListenableFuture and its Futures.transforms before taking the
// next task (<internal>). Most of MDD API should use
// ExecutionSequencer to guarantee Metadata synchronization. Currently only downloadFileGroup and
// handleTask APIs do not use ExecutionSequencer since their execution could take long time and
// using ExecutionSequencer would block other APIs.
private final ExecutionSequencer futureSerializer = ExecutionSequencer.create();
private final Optional<DownloadProgressMonitor> downloadMonitorOptional;
private final Optional<Class<?>> foregroundDownloadServiceClassOptional;
private final AsyncFunction<DataFileGroupInternal, Boolean> customFileGroupValidator;
// Synchronization will be done through sequentialControlExecutor
// Keep all the on-going foreground downloads.
@VisibleForTesting
final Map<String, ListenableFuture<ClientFileGroup>> keyToListenableFuture = new HashMap<>();
MobileDataDownloadImpl(
Context context,
EventLogger eventLogger,
MobileDataDownloadManager mobileDataDownloadManager,
Executor sequentialControlExecutor,
List<FileGroupPopulator> fileGroupPopulatorList,
Optional<TaskScheduler> taskSchedulerOptional,
SynchronousFileStorage fileStorage,
Optional<DownloadProgressMonitor> downloadMonitorOptional,
Optional<Class<?>> foregroundDownloadServiceClassOptional,
Flags flags,
Downloader singleFileDownloader,
Optional<CustomFileGroupValidator> customValidatorOptional) {
this.context = context;
this.eventLogger = eventLogger;
this.fileGroupPopulatorList = fileGroupPopulatorList;
this.taskSchedulerOptional = taskSchedulerOptional;
this.sequentialControlExecutor = sequentialControlExecutor;
this.mobileDataDownloadManager = mobileDataDownloadManager;
this.fileStorage = fileStorage;
this.downloadMonitorOptional = downloadMonitorOptional;
this.foregroundDownloadServiceClassOptional = foregroundDownloadServiceClassOptional;
this.flags = flags;
this.singleFileDownloader = singleFileDownloader;
this.customFileGroupValidator =
createCustomFileGroupValidator(
customValidatorOptional,
mobileDataDownloadManager,
sequentialControlExecutor,
fileStorage);
}
// Wraps the custom validator because the validation at a lower level of the stack where
// the ClientFileGroup is not available, yet ClientFileGroup is the client-facing API we'd
// like to expose.
private static AsyncFunction<DataFileGroupInternal, Boolean> createCustomFileGroupValidator(
Optional<CustomFileGroupValidator> validatorOptional,
MobileDataDownloadManager mobileDataDownloadManager,
Executor executor,
SynchronousFileStorage fileStorage) {
if (!validatorOptional.isPresent()) {
return unused -> Futures.immediateFuture(true);
}
return internalFileGroup ->
Futures.transformAsync(
createClientFileGroup(
internalFileGroup,
/* account= */ null,
ClientFileGroup.Status.PENDING_CUSTOM_VALIDATION,
/* preserveZipDirectories= */ false,
mobileDataDownloadManager,
executor,
fileStorage),
propagateAsyncFunction(
clientFileGroup -> validatorOptional.get().validateFileGroup(clientFileGroup)),
executor);
}
@Override
public ListenableFuture<Boolean> addFileGroup(AddFileGroupRequest addFileGroupRequest) {
return futureSerializer.submitAsync(
propagateAsyncCallable(
() -> {
LogUtil.d(
"%s: Adding for download group = '%s', variant = '%s' and associating it with"
+ " account = '%s', variant = '%s'",
TAG,
addFileGroupRequest.dataFileGroup().getGroupName(),
addFileGroupRequest.dataFileGroup().getVariantId(),
String.valueOf(addFileGroupRequest.accountOptional().orNull()),
String.valueOf(addFileGroupRequest.variantIdOptional().orNull()));
DataFileGroup dataFileGroup = addFileGroupRequest.dataFileGroup();
// Ensure that the owner package is always set as the host app.
if (!dataFileGroup.hasOwnerPackage()) {
dataFileGroup =
dataFileGroup.toBuilder().setOwnerPackage(context.getPackageName()).build();
} else if (!context.getPackageName().equals(dataFileGroup.getOwnerPackage())) {
LogUtil.e(
"%s: Added group = '%s' with wrong owner package: '%s' v.s. '%s' ",
TAG,
dataFileGroup.getGroupName(),
context.getPackageName(),
dataFileGroup.getOwnerPackage());
return Futures.immediateFuture(false);
}
GroupKey.Builder groupKeyBuilder =
GroupKey.newBuilder()
.setGroupName(dataFileGroup.getGroupName())
.setOwnerPackage(dataFileGroup.getOwnerPackage());
if (addFileGroupRequest.accountOptional().isPresent()) {
groupKeyBuilder.setAccount(
AccountUtil.serialize(addFileGroupRequest.accountOptional().get()));
}
if (addFileGroupRequest.variantIdOptional().isPresent()) {
groupKeyBuilder.setVariantId(addFileGroupRequest.variantIdOptional().get());
}
try {
DataFileGroupInternal dataFileGroupInternal =
ProtoConversionUtil.convert(dataFileGroup);
return mobileDataDownloadManager.addGroupForDownloadInternal(
groupKeyBuilder.build(), dataFileGroupInternal, customFileGroupValidator);
} catch (InvalidProtocolBufferException e) {
// TODO(b/118137672): Consider rethrow exception instead of returning false.
LogUtil.e(
e, "%s: Unable to convert from DataFileGroup to DataFileGroupInternal.", TAG);
return Futures.immediateFuture(false);
}
}),
sequentialControlExecutor);
}
// TODO: Change to return ListenableFuture<Void>.
@Override
public ListenableFuture<Boolean> removeFileGroup(RemoveFileGroupRequest removeFileGroupRequest) {
return futureSerializer.submitAsync(
() -> {
GroupKey.Builder groupKeyBuilder =
GroupKey.newBuilder()
.setGroupName(removeFileGroupRequest.groupName())
.setOwnerPackage(context.getPackageName());
if (removeFileGroupRequest.accountOptional().isPresent()) {
groupKeyBuilder.setAccount(
AccountUtil.serialize(removeFileGroupRequest.accountOptional().get()));
}
if (removeFileGroupRequest.variantIdOptional().isPresent()) {
groupKeyBuilder.setVariantId(removeFileGroupRequest.variantIdOptional().get());
}
GroupKey groupKey = groupKeyBuilder.build();
return Futures.transform(
mobileDataDownloadManager.removeFileGroup(
groupKey, removeFileGroupRequest.pendingOnly()),
voidArg -> true,
sequentialControlExecutor);
},
sequentialControlExecutor);
}
@Override
public ListenableFuture<RemoveFileGroupsByFilterResponse> removeFileGroupsByFilter(
RemoveFileGroupsByFilterRequest removeFileGroupsByFilterRequest) {
return futureSerializer.submitAsync(
() ->
FluentFuture.from(mobileDataDownloadManager.getAllFreshGroups())
.transformAsync(
allFreshGroups -> {
ImmutableSet.Builder<GroupKey> groupKeysToRemoveBuilder =
ImmutableSet.builder();
for (Pair<GroupKey, DataFileGroupInternal> keyDataFileGroupPair :
allFreshGroups) {
if (applyRemoveFileGroupsFilter(
removeFileGroupsByFilterRequest, keyDataFileGroupPair)) {
// Remove downloaded status so pending/downloaded versions of the same
// group are treated as one.
groupKeysToRemoveBuilder.add(
keyDataFileGroupPair.first.toBuilder().clearDownloaded().build());
}
}
ImmutableSet<GroupKey> groupKeysToRemove = groupKeysToRemoveBuilder.build();
if (groupKeysToRemove.isEmpty()) {
return Futures.immediateFuture(
RemoveFileGroupsByFilterResponse.newBuilder()
.setRemovedFileGroupsCount(0)
.build());
}
return Futures.transform(
mobileDataDownloadManager.removeFileGroups(groupKeysToRemove.asList()),
unused ->
RemoveFileGroupsByFilterResponse.newBuilder()
.setRemovedFileGroupsCount(groupKeysToRemove.size())
.build(),
sequentialControlExecutor);
},
sequentialControlExecutor),
sequentialControlExecutor);
}
// Perform filtering using options from RemoveFileGroupsByFilterRequest
private static boolean applyRemoveFileGroupsFilter(
RemoveFileGroupsByFilterRequest removeFileGroupsByFilterRequest,
Pair<GroupKey, DataFileGroupInternal> keyDataFileGroupPair) {
// If request filters by account, ensure account is present and is equal
Optional<Account> accountOptional = removeFileGroupsByFilterRequest.accountOptional();
if (!accountOptional.isPresent() && keyDataFileGroupPair.first.hasAccount()) {
// Account must explicitly be provided in order to remove account associated file groups.
return false;
}
if (accountOptional.isPresent()
&& !AccountUtil.serialize(accountOptional.get())
.equals(keyDataFileGroupPair.first.getAccount())) {
return false;
}
return true;
}
// TODO: Futures.immediateFuture(null) uses a different annotation for Nullable.
@SuppressWarnings("nullness")
@Override
public ListenableFuture<ClientFileGroup> getFileGroup(GetFileGroupRequest getFileGroupRequest) {
return futureSerializer.submitAsync(
() -> {
GroupKey.Builder groupKeyBuilder =
GroupKey.newBuilder()
.setGroupName(getFileGroupRequest.groupName())
.setOwnerPackage(context.getPackageName());
if (getFileGroupRequest.accountOptional().isPresent()) {
groupKeyBuilder.setAccount(
AccountUtil.serialize(getFileGroupRequest.accountOptional().get()));
}
if (getFileGroupRequest.variantIdOptional().isPresent()) {
groupKeyBuilder.setVariantId(getFileGroupRequest.variantIdOptional().get());
}
GroupKey groupKey = groupKeyBuilder.build();
return Futures.transformAsync(
mobileDataDownloadManager.getFileGroup(groupKey, /*downloaded=*/ true),
dataFileGroup ->
createClientFileGroupAndLogQueryStats(
groupKey,
dataFileGroup,
/*downloaded=*/ true,
getFileGroupRequest.preserveZipDirectories()),
sequentialControlExecutor);
},
sequentialControlExecutor);
}
private ListenableFuture<ClientFileGroup> createClientFileGroupAndLogQueryStats(
GroupKey groupKey,
@Nullable DataFileGroupInternal dataFileGroup,
boolean downloaded,
boolean preserveZipDirectories) {
return Futures.transform(
createClientFileGroup(
dataFileGroup,
groupKey.hasAccount() ? groupKey.getAccount() : null,
downloaded ? ClientFileGroup.Status.DOWNLOADED : ClientFileGroup.Status.PENDING,
preserveZipDirectories,
mobileDataDownloadManager,
sequentialControlExecutor,
fileStorage),
clientFileGroup -> {
if (clientFileGroup != null) {
eventLogger.logMddQueryStats(createFileGroupDetails(clientFileGroup));
}
return clientFileGroup;
},
sequentialControlExecutor);
}
@SuppressWarnings("nullness")
private static ListenableFuture<ClientFileGroup> createClientFileGroup(
@Nullable DataFileGroupInternal dataFileGroup,
@Nullable String account,
ClientFileGroup.Status status,
boolean preserveZipDirectories,
MobileDataDownloadManager manager,
Executor executor,
SynchronousFileStorage fileStorage) {
if (dataFileGroup == null) {
return Futures.immediateFuture(null);
}
ClientFileGroup.Builder clientFileGroupBuilderInit =
ClientFileGroup.newBuilder()
.setGroupName(dataFileGroup.getGroupName())
.setOwnerPackage(dataFileGroup.getOwnerPackage())
.setVersionNumber(dataFileGroup.getFileGroupVersionNumber())
.setBuildId(dataFileGroup.getBuildId())
.setVariantId(dataFileGroup.getVariantId())
.setStatus(status)
.addAllLocale(dataFileGroup.getLocaleList());
if (account != null) {
clientFileGroupBuilderInit.setAccount(account);
}
if (dataFileGroup.hasCustomMetadata()) {
clientFileGroupBuilderInit.setCustomMetadata(dataFileGroup.getCustomMetadata());
}
ListenableFuture<ClientFileGroup.Builder> clientFileGroupBuilderFuture =
Futures.immediateFuture(clientFileGroupBuilderInit);
for (DataFile dataFile : dataFileGroup.getFileList()) {
clientFileGroupBuilderFuture =
Futures.transformAsync(
clientFileGroupBuilderFuture,
clientFileGroupBuilder -> {
if (status == ClientFileGroup.Status.DOWNLOADED
|| status == ClientFileGroup.Status.PENDING_CUSTOM_VALIDATION) {
return Futures.transformAsync(
manager.getDataFileUri(dataFile, dataFileGroup),
fileUri -> {
if (fileUri == null) {
return Futures.immediateFailedFuture(
DownloadException.builder()
.setDownloadResultCode(
DownloadResultCode.DOWNLOADED_FILE_NOT_FOUND_ERROR)
.setMessage("getDataFileUri() resolved to null")
.build());
}
try {
if (!preserveZipDirectories && fileStorage.isDirectory(fileUri)) {
String rootPath = fileUri.getPath();
if (rootPath != null) {
clientFileGroupBuilder.addAllFile(
listAllClientFilesOfDirectory(fileStorage, fileUri, rootPath));
}
} else {
clientFileGroupBuilder.addFile(
createClientFile(
dataFile.getFileId(),
dataFile.getByteSize(),
dataFile.getDownloadedFileByteSize(),
fileUri.toString(),
dataFile.hasCustomMetadata()
? dataFile.getCustomMetadata()
: null));
}
} catch (IOException e) {
LogUtil.e(e, "Failed to list files under directory:" + fileUri);
}
return Futures.immediateFuture(clientFileGroupBuilder);
},
executor);
} else {
clientFileGroupBuilder.addFile(
createClientFile(
dataFile.getFileId(),
dataFile.getByteSize(),
dataFile.getDownloadedFileByteSize(),
/* uri = */ null,
dataFile.hasCustomMetadata() ? dataFile.getCustomMetadata() : null));
return Futures.immediateFuture(clientFileGroupBuilder);
}
},
executor);
}
return FluentFuture.from(clientFileGroupBuilderFuture)
.transform(GeneratedMessageLite.Builder::build, executor)
.catching(DownloadException.class, exn -> null, executor);
}
private static ClientFile createClientFile(
String fileId,
int byteSize,
int downloadByteSize,
@Nullable String uri,
@Nullable Any customMetadata) {
ClientFile.Builder clientFileBuilder =
ClientFile.newBuilder().setFileId(fileId).setFullSizeInBytes(byteSize);
if (downloadByteSize > 0) {
// Files with downloaded transforms like compress and zip could have different downloaded
// file size than the final file size on disk. Return the downloaded file size for client to
// track and calculate the download progress.
clientFileBuilder.setDownloadSizeInBytes(downloadByteSize);
}
if (uri != null) {
clientFileBuilder.setFileUri(uri);
}
if (customMetadata != null) {
clientFileBuilder.setCustomMetadata(customMetadata);
}
return clientFileBuilder.build();
}
private static List<ClientFile> listAllClientFilesOfDirectory(
SynchronousFileStorage fileStorage, Uri dirUri, String rootDir) throws IOException {
List<ClientFile> clientFileList = new ArrayList<>();
for (Uri childUri : fileStorage.children(dirUri)) {
if (fileStorage.isDirectory(childUri)) {
clientFileList.addAll(listAllClientFilesOfDirectory(fileStorage, childUri, rootDir));
} else {
String childPath = childUri.getPath();
if (childPath != null) {
ClientFile clientFile =
ClientFile.newBuilder()
.setFileId(childPath.replaceFirst(rootDir, ""))
.setFullSizeInBytes((int) fileStorage.fileSize(childUri))
.setFileUri(childUri.toString())
.build();
clientFileList.add(clientFile);
}
}
}
return clientFileList;
}
@Override
public ListenableFuture<ImmutableList<ClientFileGroup>> getFileGroupsByFilter(
GetFileGroupsByFilterRequest getFileGroupsByFilterRequest) {
return futureSerializer.submitAsync(
() ->
Futures.transformAsync(
mobileDataDownloadManager.getAllFreshGroups(),
allFreshGroups -> {
ListenableFuture<ImmutableList.Builder<ClientFileGroup>>
clientFileGroupsBuilderFuture =
Futures.immediateFuture(ImmutableList.<ClientFileGroup>builder());
for (Pair<GroupKey, DataFileGroupInternal> keyDataFileGroupPair :
allFreshGroups) {
clientFileGroupsBuilderFuture =
Futures.transformAsync(
clientFileGroupsBuilderFuture,
clientFileGroupsBuilder -> {
GroupKey groupKey = keyDataFileGroupPair.first;
DataFileGroupInternal dataFileGroup = keyDataFileGroupPair.second;
if (applyFilter(
getFileGroupsByFilterRequest, groupKey, dataFileGroup)) {
return Futures.transform(
createClientFileGroupAndLogQueryStats(
groupKey,
dataFileGroup,
groupKey.getDownloaded(),
getFileGroupsByFilterRequest.preserveZipDirectories()),
clientFileGroup -> {
if (clientFileGroup != null) {
clientFileGroupsBuilder.add(clientFileGroup);
}
return clientFileGroupsBuilder;
},
sequentialControlExecutor);
}
return Futures.immediateFuture(clientFileGroupsBuilder);
},
sequentialControlExecutor);
}
return Futures.transform(
clientFileGroupsBuilderFuture,
ImmutableList.Builder::build,
sequentialControlExecutor);
},
sequentialControlExecutor),
sequentialControlExecutor);
}
// Perform filtering using options from GetFileGroupsByFilterRequest
private static boolean applyFilter(
GetFileGroupsByFilterRequest getFileGroupsByFilterRequest,
GroupKey groupKey,
DataFileGroupInternal fileGroup) {
if (getFileGroupsByFilterRequest.includeAllGroups()) {
return true;
}
// If request filters by group name, ensure name is equal
Optional<String> groupNameOptional = getFileGroupsByFilterRequest.groupNameOptional();
if (groupNameOptional.isPresent()
&& !TextUtils.equals(groupNameOptional.get(), groupKey.getGroupName())) {
return false;
}
// When the caller requests account independent groups only.
if (getFileGroupsByFilterRequest.groupWithNoAccountOnly()) {
return !groupKey.hasAccount();
}
// When the caller requests account dependent groups as well.
if (getFileGroupsByFilterRequest.accountOptional().isPresent()
&& !AccountUtil.serialize(getFileGroupsByFilterRequest.accountOptional().get())
.equals(groupKey.getAccount())) {
return false;
}
return true;
}
/**
* Creates {@link IcingDataDownloadFileGroupStats} from {@link ClientFileGroup} for remote logging
* purposes.
*/
private static Void createFileGroupDetails(ClientFileGroup clientFileGroup) {
return null;
}
@Override
public ListenableFuture<Void> importFiles(ImportFilesRequest importFilesRequest) {
GroupKey.Builder groupKeyBuilder =
GroupKey.newBuilder()
.setGroupName(importFilesRequest.groupName())
.setOwnerPackage(context.getPackageName());
if (importFilesRequest.accountOptional().isPresent()) {
groupKeyBuilder.setAccount(AccountUtil.serialize(importFilesRequest.accountOptional().get()));
}
GroupKey groupKey = groupKeyBuilder.build();
ImmutableList.Builder<DataFile> updatedDataFileListBuilder =
ImmutableList.builderWithExpectedSize(importFilesRequest.updatedDataFileList().size());
for (DownloadConfigProto.DataFile dataFile : importFilesRequest.updatedDataFileList()) {
updatedDataFileListBuilder.add(ProtoConversionUtil.convertDataFile(dataFile));
}
return futureSerializer.submitAsync(
() ->
mobileDataDownloadManager.importFiles(
groupKey,
importFilesRequest.buildId(),
importFilesRequest.variantId(),
updatedDataFileListBuilder.build(),
importFilesRequest.inlineFileMap(),
importFilesRequest.customPropertyOptional(),
customFileGroupValidator),
sequentialControlExecutor);
}
@Override
public ListenableFuture<Void> downloadFile(SingleFileDownloadRequest singleFileDownloadRequest) {
return singleFileDownloader.download(
MddLiteConversionUtil.convertToDownloadRequest(singleFileDownloadRequest));
}
@Override
public ListenableFuture<ClientFileGroup> downloadFileGroup(
DownloadFileGroupRequest downloadFileGroupRequest) {
String groupName = downloadFileGroupRequest.groupName();
GroupKey.Builder groupKeyBuilder =
GroupKey.newBuilder().setGroupName(groupName).setOwnerPackage(context.getPackageName());
if (downloadFileGroupRequest.accountOptional().isPresent()) {
groupKeyBuilder.setAccount(
AccountUtil.serialize(downloadFileGroupRequest.accountOptional().get()));
}
if (downloadFileGroupRequest.variantIdOptional().isPresent()) {
groupKeyBuilder.setVariantId(downloadFileGroupRequest.variantIdOptional().get());
}
GroupKey groupKey = groupKeyBuilder.build();
ListenableFuture<ClientFileGroup> downloadFuture =
Futures.submitAsync(
() -> {
if (downloadFileGroupRequest.listenerOptional().isPresent()) {
if (downloadMonitorOptional.isPresent()) {
downloadMonitorOptional
.get()
.addDownloadListener(
groupName, downloadFileGroupRequest.listenerOptional().get());
} else {
return Futures.immediateFailedFuture(
DownloadException.builder()
.setDownloadResultCode(
DownloadResultCode.DOWNLOAD_MONITOR_NOT_PROVIDED_ERROR)
.setMessage(
"downloadFileGroup: DownloadListener is present but Download Monitor"
+ " is not provided!")
.build());
}
}
Optional<DownloadConditions> downloadConditions =
downloadFileGroupRequest.downloadConditionsOptional().isPresent()
? Optional.of(
ProtoConversionUtil.convert(
downloadFileGroupRequest.downloadConditionsOptional().get()))
: Optional.absent();
ListenableFuture<DataFileGroupInternal> downloadFileGroupFuture =
mobileDataDownloadManager.downloadFileGroup(
groupKey, downloadConditions, customFileGroupValidator);
return Futures.transformAsync(
downloadFileGroupFuture,
dataFileGroup -> {
return Futures.transform(
createClientFileGroup(
dataFileGroup,
downloadFileGroupRequest.accountOptional().isPresent()
? AccountUtil.serialize(
downloadFileGroupRequest.accountOptional().get())
: null,
ClientFileGroup.Status.DOWNLOADED,
downloadFileGroupRequest.preserveZipDirectories(),
mobileDataDownloadManager,
sequentialControlExecutor,
fileStorage),
Preconditions::checkNotNull,
sequentialControlExecutor);
},
sequentialControlExecutor);
},
sequentialControlExecutor);
ListenableFuture<ClientFileGroup> transformFuture =
Futures.transform(
downloadFuture,
clientFileGroup -> {
if (downloadFileGroupRequest.listenerOptional().isPresent()) {
downloadFileGroupRequest.listenerOptional().get().onComplete(clientFileGroup);
if (downloadMonitorOptional.isPresent()) {
downloadMonitorOptional.get().removeDownloadListener(groupName);
}
}
return clientFileGroup;
},
sequentialControlExecutor);
Futures.addCallback(
transformFuture,
new FutureCallback<ClientFileGroup>() {
@Override
public void onSuccess(ClientFileGroup result) {}
@Override
public void onFailure(Throwable t) {
if (downloadFileGroupRequest.listenerOptional().isPresent()
&& downloadMonitorOptional.isPresent()) {
downloadMonitorOptional.get().removeDownloadListener(groupName);
}
}
},
sequentialControlExecutor);
return transformFuture;
}
@Override
public ListenableFuture<Void> downloadFileWithForegroundService(
SingleFileDownloadRequest singleFileDownloadRequest) {
return singleFileDownloader.downloadWithForegroundService(
MddLiteConversionUtil.convertToDownloadRequest(singleFileDownloadRequest));
}
@Override
public ListenableFuture<ClientFileGroup> downloadFileGroupWithForegroundService(
DownloadFileGroupRequest downloadFileGroupRequest) {
LogUtil.d("%s: downloadFileGroupWithForegroundService start.", TAG);
if (!foregroundDownloadServiceClassOptional.isPresent()) {
return Futures.immediateFailedFuture(
new IllegalArgumentException(
"downloadFileGroupWithForegroundService: ForegroundDownloadService is not"
+ " provided!"));
}
if (!downloadMonitorOptional.isPresent()) {
return Futures.immediateFailedFuture(
DownloadException.builder()
.setDownloadResultCode(DownloadResultCode.DOWNLOAD_MONITOR_NOT_PROVIDED_ERROR)
.setMessage(
"downloadFileGroupWithForegroundService: Download Monitor is not provided!")
.build());
}
// It's OK to recreate the NotificationChannel since it can also be used to restore a
// deleted channel and to update an existing channel's name, description, group, and/or
// importance.
NotificationUtil.createNotificationChannel(context);
String groupName = downloadFileGroupRequest.groupName();
GroupKey.Builder groupKeyBuilder =
GroupKey.newBuilder().setGroupName(groupName).setOwnerPackage(context.getPackageName());
if (downloadFileGroupRequest.accountOptional().isPresent()) {
groupKeyBuilder.setAccount(
AccountUtil.serialize(downloadFileGroupRequest.accountOptional().get()));
}
if (downloadFileGroupRequest.variantIdOptional().isPresent()) {
groupKeyBuilder.setVariantId(downloadFileGroupRequest.variantIdOptional().get());
}
GroupKey groupKey = groupKeyBuilder.build();
ListenableFuture<ClientFileGroup> downloadFuture =
Futures.transformAsync(
// Check if requested file group has already been downloaded
tryToGetDownloadedFileGroup(downloadFileGroupRequest),
downloadedFileGroupOptional -> {
// If the file group has already been downloaded, return that one.
if (downloadedFileGroupOptional.isPresent()) {
return Futures.immediateFuture(downloadedFileGroupOptional.get());
}
// if there is the same on-going request, return that one.
if (keyToListenableFuture.containsKey(downloadFileGroupRequest.groupName())) {
// keyToListenableFuture.get must return Non-null since we check the containsKey
// above.
// checkNotNull is to suppress false alarm about @Nullable result.
return Preconditions.checkNotNull(
keyToListenableFuture.get(downloadFileGroupRequest.groupName()));
}
// Only start the foreground download service when this is the first download
// request.
if (keyToListenableFuture.isEmpty()) {
NotificationUtil.startForegroundDownloadService(
context,
foregroundDownloadServiceClassOptional.get(),
downloadFileGroupRequest.groupName());
}
DownloadListener downloadListenerWithNotification =
createDownloadListenerWithNotification(downloadFileGroupRequest);
// The downloadMonitor will trigger the DownloadListener.
downloadMonitorOptional
.get()
.addDownloadListener(
downloadFileGroupRequest.groupName(), downloadListenerWithNotification);
Optional<DownloadConditions> downloadConditions =
downloadFileGroupRequest.downloadConditionsOptional().isPresent()
? Optional.of(
ProtoConversionUtil.convert(
downloadFileGroupRequest.downloadConditionsOptional().get()))
: Optional.absent();
ListenableFuture<DataFileGroupInternal> downloadFileGroupFuture =
mobileDataDownloadManager.downloadFileGroup(
groupKey, downloadConditions, customFileGroupValidator);
ListenableFuture<ClientFileGroup> transformFuture =
Futures.transformAsync(
downloadFileGroupFuture,
dataFileGroup -> {
return Futures.transform(
createClientFileGroup(
dataFileGroup,
downloadFileGroupRequest.accountOptional().isPresent()
? AccountUtil.serialize(
downloadFileGroupRequest.accountOptional().get())
: null,
ClientFileGroup.Status.DOWNLOADED,
downloadFileGroupRequest.preserveZipDirectories(),
mobileDataDownloadManager,
sequentialControlExecutor,
fileStorage),
Preconditions::checkNotNull,
sequentialControlExecutor);
},
sequentialControlExecutor);
Futures.addCallback(
transformFuture,
new FutureCallback<ClientFileGroup>() {
@Override
public void onSuccess(ClientFileGroup clientFileGroup) {
// Currently the MobStore monitor does not support onSuccess so we have to add
// callback to the download future here.
// TODO(b/148057674): Use the same logic as MDDLite to keep the foreground
// download service alive until the client's onComplete finishes.
downloadListenerWithNotification.onComplete(clientFileGroup);
}
@Override
public void onFailure(Throwable t) {
// Currently the MobStore monitor does not support onFailure so we have to add
// callback to the download future here.
downloadListenerWithNotification.onFailure(t);
}
},
sequentialControlExecutor);
keyToListenableFuture.put(downloadFileGroupRequest.groupName(), transformFuture);
return transformFuture;
},
sequentialControlExecutor);
return downloadFuture;
}
/** Helper method to check if file group has been downloaded and return it early. */
private ListenableFuture<Optional<ClientFileGroup>> tryToGetDownloadedFileGroup(
DownloadFileGroupRequest downloadFileGroupRequest) {
String groupName = downloadFileGroupRequest.groupName();
GroupKey.Builder groupKeyBuilder =
GroupKey.newBuilder().setGroupName(groupName).setOwnerPackage(context.getPackageName());
if (downloadFileGroupRequest.accountOptional().isPresent()) {
groupKeyBuilder.setAccount(
AccountUtil.serialize(downloadFileGroupRequest.accountOptional().get()));
}
boolean isDownloadListenerPresent = downloadFileGroupRequest.listenerOptional().isPresent();
GroupKey groupKey = groupKeyBuilder.build();
// Get pending and downloaded versions to tell if we should return downloaded version early
ListenableFuture<Pair<DataFileGroupInternal, DataFileGroupInternal>> fileGroupVersionsFuture =
Futures.transformAsync(
mobileDataDownloadManager.getFileGroup(groupKey, /* downloaded = */ false),
pendingDataFileGroup ->
Futures.transform(
mobileDataDownloadManager.getFileGroup(groupKey, /* downloaded = */ true),
downloadedDataFileGroup ->
Pair.create(pendingDataFileGroup, downloadedDataFileGroup),
sequentialControlExecutor),
sequentialControlExecutor);
return Futures.transformAsync(
fileGroupVersionsFuture,
fileGroupVersionsPair -> {
// if pending version is not null, return absent
if (fileGroupVersionsPair.first != null) {
return Futures.immediateFuture(Optional.absent());
}
// If both groups are null, return group not found failure
if (fileGroupVersionsPair.second == null) {
// TODO(b/174808410): Add Logging
// file group is not pending nor downloaded -- return failure.
DownloadException failure =
DownloadException.builder()
.setDownloadResultCode(DownloadResultCode.GROUP_NOT_FOUND_ERROR)
.setMessage("Nothing to download for file group: " + groupKey.getGroupName())
.build();
if (isDownloadListenerPresent) {
downloadFileGroupRequest.listenerOptional().get().onFailure(failure);
}
return Futures.immediateFailedFuture(failure);
}
DataFileGroupInternal downloadedDataFileGroup = fileGroupVersionsPair.second;
// Notify download listener (if present) that file group has been downloaded.
if (isDownloadListenerPresent) {
downloadMonitorOptional
.get()
.addDownloadListener(
downloadFileGroupRequest.groupName(),
downloadFileGroupRequest.listenerOptional().get());
}
FluentFuture<Optional<ClientFileGroup>> transformFuture =
FluentFuture.from(
createClientFileGroup(
downloadedDataFileGroup,
downloadFileGroupRequest.accountOptional().isPresent()
? AccountUtil.serialize(
downloadFileGroupRequest.accountOptional().get())
: null,
ClientFileGroup.Status.DOWNLOADED,
downloadFileGroupRequest.preserveZipDirectories(),
mobileDataDownloadManager,
sequentialControlExecutor,
fileStorage))
.transform(Preconditions::checkNotNull, sequentialControlExecutor)
.transform(
clientFileGroup -> {
if (isDownloadListenerPresent) {
downloadFileGroupRequest
.listenerOptional()
.get()
.onComplete(clientFileGroup);
downloadMonitorOptional.get().removeDownloadListener(groupName);
}
return Optional.of(clientFileGroup);
},
sequentialControlExecutor);
transformFuture.addCallback(
new FutureCallback<Optional<ClientFileGroup>>() {
@Override
public void onSuccess(Optional<ClientFileGroup> result) {}
@Override
public void onFailure(Throwable t) {
if (isDownloadListenerPresent) {
downloadMonitorOptional.get().removeDownloadListener(groupName);
}
}
},
sequentialControlExecutor);
return transformFuture;
},
sequentialControlExecutor);
}
private DownloadListener createDownloadListenerWithNotification(
DownloadFileGroupRequest downloadRequest) {
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
NotificationCompat.Builder notification =
NotificationUtil.createNotificationBuilder(
context,
downloadRequest.groupSizeBytes(),
downloadRequest.contentTitleOptional().or(downloadRequest.groupName()),
downloadRequest.contentTextOptional().or(downloadRequest.groupName()));
int notificationKey = NotificationUtil.notificationKeyForKey(downloadRequest.groupName());
if (downloadRequest.showNotifications() == DownloadFileGroupRequest.ShowNotifications.ALL) {
NotificationUtil.createCancelAction(
context,
foregroundDownloadServiceClassOptional.get(),
downloadRequest.groupName(),
notification,
notificationKey);
notificationManager.notify(notificationKey, notification.build());
}
return new DownloadListener() {
@Override
public void onProgress(long currentSize) {
sequentialControlExecutor.execute(
() -> {
// There can be a race condition, where onPausedForConnectivity can be called
// after onComplete or onFailure which removes the future and the notification.
if (keyToListenableFuture.containsKey(downloadRequest.groupName())
&& downloadRequest.showNotifications()
== DownloadFileGroupRequest.ShowNotifications.ALL) {
notification
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setProgress(
downloadRequest.groupSizeBytes(),
(int) currentSize,
/* indeterminate = */ downloadRequest.groupSizeBytes() <= 0);
notificationManager.notify(notificationKey, notification.build());
}
if (downloadRequest.listenerOptional().isPresent()) {
downloadRequest.listenerOptional().get().onProgress(currentSize);
}
});
}
@Override
public void pausedForConnectivity() {
sequentialControlExecutor.execute(
() -> {
// There can be a race condition, where pausedForConnectivity can be called
// after onComplete or onFailure which removes the future and the notification.
if (keyToListenableFuture.containsKey(downloadRequest.groupName())
&& downloadRequest.showNotifications()
== DownloadFileGroupRequest.ShowNotifications.ALL) {
notification
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setContentText(NotificationUtil.getDownloadPausedMessage(context))
.setSmallIcon(android.R.drawable.stat_sys_download)
.setOngoing(true)
// hide progress bar.
.setProgress(0, 0, false);
notificationManager.notify(notificationKey, notification.build());
}
if (downloadRequest.listenerOptional().isPresent()) {
downloadRequest.listenerOptional().get().pausedForConnectivity();
}
});
}
@Override
public void onComplete(ClientFileGroup clientFileGroup) {
sequentialControlExecutor.execute(
() -> {
// Clear the notification action.
if (downloadRequest.showNotifications()
== DownloadFileGroupRequest.ShowNotifications.ALL) {
notification.mActions.clear();
NotificationUtil.cancelNotificationForKey(context, downloadRequest.groupName());
}
keyToListenableFuture.remove(downloadRequest.groupName());
// If there is no other on-going foreground download, shutdown the
// ForegroundDownloadService
if (keyToListenableFuture.isEmpty()) {
NotificationUtil.stopForegroundDownloadService(
context, foregroundDownloadServiceClassOptional.get());
}
if (downloadRequest.listenerOptional().isPresent()) {
downloadRequest.listenerOptional().get().onComplete(clientFileGroup);
}
downloadMonitorOptional.get().removeDownloadListener(downloadRequest.groupName());
});
}
@Override
public void onFailure(Throwable t) {
sequentialControlExecutor.execute(
() -> {
if (downloadRequest.showNotifications()
== DownloadFileGroupRequest.ShowNotifications.ALL) {
// Clear the notification action.
notification.mActions.clear();
// Show download failed in notification.
notification
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setContentText(NotificationUtil.getDownloadFailedMessage(context))
.setOngoing(false)
.setSmallIcon(android.R.drawable.stat_sys_warning)
// hide progress bar.
.setProgress(0, 0, false);
notificationManager.notify(notificationKey, notification.build());
}
keyToListenableFuture.remove(downloadRequest.groupName());
// If there is no other on-going foreground download, shutdown the
// ForegroundDownloadService
if (keyToListenableFuture.isEmpty()) {
NotificationUtil.stopForegroundDownloadService(
context, foregroundDownloadServiceClassOptional.get());
}
if (downloadRequest.listenerOptional().isPresent()) {
downloadRequest.listenerOptional().get().onFailure(t);
}
downloadMonitorOptional.get().removeDownloadListener(downloadRequest.groupName());
});
}
};
}
@Override
public void cancelForegroundDownload(String downloadKey) {
LogUtil.d("%s: CancelForegroundDownload for key = %s", TAG, downloadKey);
sequentialControlExecutor.execute(
() -> {
if (keyToListenableFuture.containsKey(downloadKey)) {
keyToListenableFuture.get(downloadKey).cancel(true);
} else {
// downloadKey is not a file group, attempt cancel with internal MDD Lite instance in
// case it's a single file uri (cancel call is a noop if internal MDD Lite doesn't know
// about it).
singleFileDownloader.cancelForegroundDownload(downloadKey);
}
});
}
@Override
public void schedulePeriodicTasks() {
schedulePeriodicTasksInternal(Optional.absent());
}
@Override
public ListenableFuture<Void> schedulePeriodicBackgroundTasks() {
return futureSerializer.submit(
propagateCallable(
() -> {
schedulePeriodicTasksInternal(/* constraintOverridesMap = */ Optional.absent());
return null;
}),
sequentialControlExecutor);
}
@Override
public ListenableFuture<Void> schedulePeriodicBackgroundTasks(
Optional<Map<String, ConstraintOverrides>> constraintOverridesMap) {
return futureSerializer.submit(
propagateCallable(
() -> {
schedulePeriodicTasksInternal(constraintOverridesMap);
return null;
}),
sequentialControlExecutor);
}
private void schedulePeriodicTasksInternal(
Optional<Map<String, ConstraintOverrides>> constraintOverridesMap) {
if (!taskSchedulerOptional.isPresent()) {
LogUtil.e(
"%s: Called schedulePeriodicTasksInternal when taskScheduler is not provided.", TAG);
return;
}
TaskScheduler taskScheduler = taskSchedulerOptional.get();
// Schedule task that runs on charging without any network, every 6 hours.
taskScheduler.schedulePeriodicTask(
TaskScheduler.CHARGING_PERIODIC_TASK,
flags.chargingGcmTaskPeriod(),
NetworkState.NETWORK_STATE_ANY,
getConstraintOverrides(constraintOverridesMap, TaskScheduler.CHARGING_PERIODIC_TASK));
// Schedule maintenance task that runs on charging, once every day.
// This task should run even if mdd is disabled, to handle cleanup.
taskScheduler.schedulePeriodicTask(
TaskScheduler.MAINTENANCE_PERIODIC_TASK,
flags.maintenanceGcmTaskPeriod(),
NetworkState.NETWORK_STATE_ANY,
getConstraintOverrides(constraintOverridesMap, TaskScheduler.MAINTENANCE_PERIODIC_TASK));
// Schedule task that runs on cellular+charging, every 6 hours.
taskScheduler.schedulePeriodicTask(
TaskScheduler.CELLULAR_CHARGING_PERIODIC_TASK,
flags.cellularChargingGcmTaskPeriod(),
NetworkState.NETWORK_STATE_CONNECTED,
getConstraintOverrides(
constraintOverridesMap, TaskScheduler.CELLULAR_CHARGING_PERIODIC_TASK));
// Schedule task that runs on wifi+charging, every 6 hours.
taskScheduler.schedulePeriodicTask(
TaskScheduler.WIFI_CHARGING_PERIODIC_TASK,
flags.wifiChargingGcmTaskPeriod(),
NetworkState.NETWORK_STATE_UNMETERED,
getConstraintOverrides(constraintOverridesMap, TaskScheduler.WIFI_CHARGING_PERIODIC_TASK));
}
private static Optional<ConstraintOverrides> getConstraintOverrides(
Optional<Map<String, ConstraintOverrides>> constraintOverridesMap,
String maintenancePeriodicTask) {
return constraintOverridesMap.isPresent()
? Optional.fromNullable(constraintOverridesMap.get().get(maintenancePeriodicTask))
: Optional.absent();
}
@Override
public ListenableFuture<Void> handleTask(String tag) {
// All work done here that touches metadata (MobileDataDownloadManager) should be serialized
// through sequentialControlExecutor.
switch (tag) {
case TaskScheduler.MAINTENANCE_PERIODIC_TASK:
return futureSerializer.submitAsync(
mobileDataDownloadManager::maintenance, sequentialControlExecutor);
case TaskScheduler.CHARGING_PERIODIC_TASK:
ListenableFuture<Void> refreshFileGroupsFuture = refreshFileGroups();
return Futures.transformAsync(
refreshFileGroupsFuture,
propagateAsyncFunction(
v -> mobileDataDownloadManager.verifyAllPendingGroups(customFileGroupValidator)),
sequentialControlExecutor);
case TaskScheduler.CELLULAR_CHARGING_PERIODIC_TASK:
return refreshAndDownload(false /*onWifi*/);
case TaskScheduler.WIFI_CHARGING_PERIODIC_TASK:
return refreshAndDownload(true /*onWifi*/);
default:
LogUtil.d("%s: gcm task doesn't belong to MDD", TAG);
return Futures.immediateFailedFuture(
new IllegalArgumentException("Unknown task tag sent to MDD.handleTask() " + tag));
}
}
private ListenableFuture<Void> refreshAndDownload(boolean onWifi) {
// We will do 2 passes to support 2-step downloads. In each step, we will refresh and then
// download.
return FluentFuture.from(refreshFileGroups())
.transformAsync(
v ->
mobileDataDownloadManager.downloadAllPendingGroups(
onWifi, customFileGroupValidator),
sequentialControlExecutor)
.transformAsync(v -> refreshFileGroups(), sequentialControlExecutor)
.transformAsync(
v ->
mobileDataDownloadManager.downloadAllPendingGroups(
onWifi, customFileGroupValidator),
sequentialControlExecutor);
}
private ListenableFuture<Void> refreshFileGroups() {
List<ListenableFuture<Void>> refreshFutures = new ArrayList<>();
for (FileGroupPopulator fileGroupPopulator : fileGroupPopulatorList) {
refreshFutures.add(fileGroupPopulator.refreshFileGroups(this));
}
return Futures.whenAllComplete(refreshFutures).call(() -> null, sequentialControlExecutor);
}
@Override
public ListenableFuture<Void> maintenance() {
return handleTask(TaskScheduler.MAINTENANCE_PERIODIC_TASK);
}
@Override
public ListenableFuture<Void> clear() {
return futureSerializer.submitAsync(
mobileDataDownloadManager::clear, sequentialControlExecutor);
}
// incompatible argument for parameter msg of e.
// incompatible types in return.
@Override
public String getDebugInfoAsString() {
ByteArrayOutputStream out = new ByteArrayOutputStream();
PrintWriter writer = new PrintWriter(out);
try {
// Okay to block here because this method is for debugging only.
mobileDataDownloadManager.dump(writer).get(DUMP_DEBUG_INFO_TIMEOUT, TimeUnit.SECONDS);
writer.println("==== MOBSTORE_DEBUG_INFO ====");
writer.print(fileStorage.getDebugInfo());
} catch (ExecutionException | TimeoutException e) {
String errString = String.format("%s: Couldn't get debug info: %s", TAG, e);
LogUtil.e(errString);
return errString;
} catch (InterruptedException e) {
// see <internal>
Thread.currentThread().interrupt();
String errString = String.format("%s: Couldn't get debug info: %s", TAG, e);
LogUtil.e(errString);
return errString;
}
writer.flush();
return out.toString();
}
@Override
public ListenableFuture<Void> reportUsage(UsageEvent usageEvent) {
eventLogger.logMddUsageEvent(createFileGroupDetails(usageEvent.clientFileGroup()), null);
return Futures.immediateVoidFuture();
}
}