blob: 1c0cb49863b1c7deb2743d7be5675174cce17241 [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.lite;
import android.content.Context;
import androidx.annotation.VisibleForTesting;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import com.google.android.libraries.mobiledatadownload.DownloadException;
import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode;
import com.google.android.libraries.mobiledatadownload.downloader.FileDownloader;
import com.google.android.libraries.mobiledatadownload.foreground.NotificationUtil;
import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import com.google.common.base.Supplier;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Executor;
import org.checkerframework.checker.nullness.compatqual.NullableDecl;
final class DownloaderImpl implements Downloader {
private static final String TAG = "DownloaderImp";
private final Context context;
private final Optional<Class<?>> foregroundDownloadServiceClassOptional;
// This executor will execute tasks sequentially.
private final Executor sequentialControlExecutor;
private final Optional<SingleFileDownloadProgressMonitor> downloadMonitorOptional;
private final Supplier<FileDownloader> fileDownloaderSupplier;
// Synchronization will be done through sequentialControlExecutor
@VisibleForTesting
final Map<String, ListenableFuture<Void>> keyToListenableFuture = new HashMap<>();
DownloaderImpl(
Context context,
Optional<Class<?>> foregroundDownloadServiceClassOptional,
Executor sequentialControlExecutor,
Optional<SingleFileDownloadProgressMonitor> downloadMonitorOptional,
Supplier<FileDownloader> fileDownloaderSupplier) {
this.context = context;
this.sequentialControlExecutor = sequentialControlExecutor;
this.foregroundDownloadServiceClassOptional = foregroundDownloadServiceClassOptional;
this.downloadMonitorOptional = downloadMonitorOptional;
this.fileDownloaderSupplier = fileDownloaderSupplier;
}
@Override
public ListenableFuture<Void> download(DownloadRequest downloadRequest) {
LogUtil.d("%s: download for Uri = %s", TAG, downloadRequest.destinationFileUri().toString());
return Futures.submitAsync(
() -> {
// if there is the same on-going request, return that one.
if (keyToListenableFuture.containsKey(downloadRequest.destinationFileUri().toString())) {
// uriToListenableFuture.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(downloadRequest.destinationFileUri().toString()));
}
// Register listener with monitor if present
if (downloadRequest.listenerOptional().isPresent()) {
if (downloadMonitorOptional.isPresent()) {
downloadMonitorOptional
.get()
.addDownloadListener(
downloadRequest.destinationFileUri(),
downloadRequest.listenerOptional().get());
} else {
LogUtil.w(
"%s: download request included DownloadListener, but DownloadMonitor is not"
+ " present! DownloadListener will only be invoked for complete/failure.");
}
}
ListenableFuture<Void> downloadFuture = startDownload(downloadRequest);
Futures.addCallback(
downloadFuture,
new FutureCallback<Void>() {
@Override
public void onSuccess(Void result) {
// Currently the MobStore monitor does not support onSuccess so we have to add
// callback to the download future here.
// Remove download listener and remove download future from map after listener
// completes
if (downloadRequest.listenerOptional().isPresent()) {
Futures.addCallback(
downloadRequest.listenerOptional().get().onComplete(),
new FutureCallback<Void>() {
@Override
public void onSuccess(@NullableDecl Void result) {
keyToListenableFuture.remove(
downloadRequest.destinationFileUri().toString());
if (downloadMonitorOptional.isPresent()) {
downloadMonitorOptional
.get()
.removeDownloadListener(downloadRequest.destinationFileUri());
}
}
@Override
public void onFailure(Throwable t) {
LogUtil.e(t, "%s: Failed to run client onComplete", TAG);
keyToListenableFuture.remove(
downloadRequest.destinationFileUri().toString());
if (downloadMonitorOptional.isPresent()) {
downloadMonitorOptional
.get()
.removeDownloadListener(downloadRequest.destinationFileUri());
}
}
},
sequentialControlExecutor);
} else {
// remove from future map immediately
keyToListenableFuture.remove(downloadRequest.destinationFileUri().toString());
}
}
@Override
public void onFailure(Throwable t) {
LogUtil.e(t, "%s: Download Future failed", TAG);
// Currently the MobStore monitor does not support onFailure so we have to add
// callback to the download future here.
if (downloadRequest.listenerOptional().isPresent()) {
downloadRequest.listenerOptional().get().onFailure(t);
if (downloadMonitorOptional.isPresent()) {
downloadMonitorOptional
.get()
.removeDownloadListener(downloadRequest.destinationFileUri());
}
}
keyToListenableFuture.remove(downloadRequest.destinationFileUri().toString());
}
},
MoreExecutors.directExecutor());
keyToListenableFuture.put(
downloadRequest.destinationFileUri().toString(), downloadFuture);
return downloadFuture;
},
sequentialControlExecutor);
}
private ListenableFuture<Void> startDownload(DownloadRequest downloadRequest) {
// Translate from MDDLite DownloadRequest to MDDDownloader DownloadRequest.
com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest
fileDownloaderRequest =
com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest.newBuilder()
.setFileUri(downloadRequest.destinationFileUri())
.setDownloadConstraints(downloadRequest.downloadConstraints())
.setUrlToDownload(downloadRequest.urlToDownload())
.setExtraHttpHeaders(downloadRequest.extraHttpHeaders())
.setTrafficTag(downloadRequest.trafficTag())
.build();
try {
return fileDownloaderSupplier.get().startDownloading(fileDownloaderRequest);
} catch (RuntimeException e) {
// Catch any unchecked exceptions that prevented the download from starting.
return Futures.immediateFailedFuture(
DownloadException.builder()
.setDownloadResultCode(DownloadResultCode.UNKNOWN_ERROR)
.setCause(e)
.build());
}
}
@Override
public ListenableFuture<Void> downloadWithForegroundService(DownloadRequest downloadRequest) {
LogUtil.d(
"%s: downloadWithForegroundService for Uri = %s",
TAG, downloadRequest.destinationFileUri().toString());
if (!downloadMonitorOptional.isPresent()) {
return Futures.immediateFailedFuture(
new IllegalStateException(
"downloadWithForegroundService: DownloadMonitor is not provided!"));
}
if (!foregroundDownloadServiceClassOptional.isPresent()) {
return Futures.immediateFailedFuture(
new IllegalStateException(
"downloadWithForegroundService: ForegroundDownloadService is not provided!"));
}
return Futures.submitAsync(
() -> {
// if there is the same on-going request, return that one.
if (keyToListenableFuture.containsKey(downloadRequest.destinationFileUri().toString())) {
// uriToListenableFuture.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(downloadRequest.destinationFileUri().toString()));
}
// 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);
// Only start the foreground download service when there is the first download request.
if (keyToListenableFuture.isEmpty()) {
NotificationUtil.startForegroundDownloadService(
context,
foregroundDownloadServiceClassOptional.get(),
downloadRequest.destinationFileUri().toString());
}
DownloadListener downloadListenerWithNotification =
createDownloadListenerWithNotification(downloadRequest);
// The downloadMonitor will trigger the DownloadListener.
downloadMonitorOptional
.get()
.addDownloadListener(
downloadRequest.destinationFileUri(), downloadListenerWithNotification);
ListenableFuture<Void> downloadFuture = startDownload(downloadRequest);
Futures.addCallback(
downloadFuture,
new FutureCallback<Void>() {
@Override
public void onSuccess(Void result) {
// Currently the MobStore monitor does not support onSuccess so we have to add
// callback to the download future here.
Futures.addCallback(
downloadListenerWithNotification.onComplete(),
new FutureCallback<Void>() {
@Override
public void onSuccess(@NullableDecl Void result) {}
@Override
public void onFailure(Throwable t) {
LogUtil.e(t, "%s: Failed to run client onComplete", TAG);
}
},
sequentialControlExecutor);
}
@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.
LogUtil.e(t, "%s: Download Future failed", TAG);
downloadListenerWithNotification.onFailure(t);
}
},
MoreExecutors.directExecutor());
keyToListenableFuture.put(
downloadRequest.destinationFileUri().toString(), downloadFuture);
return downloadFuture;
},
sequentialControlExecutor);
}
// Assertion: foregroundDownloadService and downloadMonitor are present
private DownloadListener createDownloadListenerWithNotification(DownloadRequest downloadRequest) {
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
NotificationCompat.Builder notification =
NotificationUtil.createNotificationBuilder(
context,
downloadRequest.fileSizeBytes(),
downloadRequest.notificationContentTitle(),
downloadRequest.notificationContentTextOptional().or(downloadRequest.urlToDownload()));
int notificationKey =
NotificationUtil.notificationKeyForKey(downloadRequest.destinationFileUri().toString());
// Attach the Cancel action to the notification.
NotificationUtil.createCancelAction(
context,
foregroundDownloadServiceClassOptional.get(),
downloadRequest.destinationFileUri().toString(),
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.destinationFileUri().toString())) {
notification
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setProgress(
downloadRequest.fileSizeBytes(),
(int) currentSize,
/* indeterminate = */ downloadRequest.fileSizeBytes() <= 0);
notificationManager.notify(notificationKey, notification.build());
}
if (downloadRequest.listenerOptional().isPresent()) {
downloadRequest.listenerOptional().get().onProgress(currentSize);
}
});
}
@Override
public void onPausedForConnectivity() {
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.destinationFileUri().toString())) {
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().onPausedForConnectivity();
}
});
}
@Override
public ListenableFuture<Void> onComplete() {
// We want to keep the Foreground Download Service alive until client's onComplete finishes.
ListenableFuture<Void> clientOnCompleteFuture =
downloadRequest.listenerOptional().isPresent()
? downloadRequest.listenerOptional().get().onComplete()
: Futures.immediateVoidFuture();
// Logic to shutdown Foreground Download Service after the client's provided onComplete
// finished
clientOnCompleteFuture.addListener(
() -> {
// Clear the notification action.
notification.mActions.clear();
if (downloadRequest.showDownloadedNotification()) {
notification
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setContentText(NotificationUtil.getDownloadSuccessMessage(context))
.setOngoing(false)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
// hide progress bar.
.setProgress(0, 0, false);
notificationManager.notify(notificationKey, notification.build());
} else {
NotificationUtil.cancelNotificationForKey(
context, downloadRequest.destinationFileUri().toString());
}
keyToListenableFuture.remove(downloadRequest.destinationFileUri().toString());
// If there is no other on-going foreground download, shutdown the
// ForegroundDownloadService
if (keyToListenableFuture.isEmpty()) {
NotificationUtil.stopForegroundDownloadService(
context, foregroundDownloadServiceClassOptional.get());
}
downloadMonitorOptional
.get()
.removeDownloadListener(downloadRequest.destinationFileUri());
},
sequentialControlExecutor);
return clientOnCompleteFuture;
}
@Override
public void onFailure(Throwable t) {
sequentialControlExecutor.execute(
() -> {
// 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.destinationFileUri().toString());
// 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.destinationFileUri());
});
}
};
}
@Override
public void cancelForegroundDownload(String destinationFileUri) {
LogUtil.d("%s: CancelForegroundDownload for Uri = %s", TAG, destinationFileUri);
sequentialControlExecutor.execute(
() -> {
if (keyToListenableFuture.containsKey(destinationFileUri)) {
keyToListenableFuture.get(destinationFileUri).cancel(true);
}
});
}
}