/* * 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 static com.google.common.util.concurrent.Futures.immediateFailedFuture; import static com.google.common.util.concurrent.Futures.immediateVoidFuture; 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.ForegroundDownloadKey; import com.google.android.libraries.mobiledatadownload.foreground.NotificationUtil; import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil; import com.google.android.libraries.mobiledatadownload.internal.util.DownloadFutureMap; import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFluentFuture; import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFutures; import com.google.common.base.Optional; import com.google.common.base.Supplier; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFutureTask; import com.google.common.util.concurrent.MoreExecutors; 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> foregroundDownloadServiceClassOptional; // This executor will execute tasks sequentially. private final Executor sequentialControlExecutor; private final Optional downloadMonitorOptional; private final Supplier fileDownloaderSupplier; @VisibleForTesting final DownloadFutureMap downloadFutureMap; @VisibleForTesting final DownloadFutureMap foregroundDownloadFutureMap; DownloaderImpl( Context context, Optional> foregroundDownloadServiceClassOptional, Executor sequentialControlExecutor, Optional downloadMonitorOptional, Supplier fileDownloaderSupplier) { this.context = context; this.sequentialControlExecutor = sequentialControlExecutor; this.foregroundDownloadServiceClassOptional = foregroundDownloadServiceClassOptional; this.downloadMonitorOptional = downloadMonitorOptional; this.fileDownloaderSupplier = fileDownloaderSupplier; this.downloadFutureMap = DownloadFutureMap.create(sequentialControlExecutor); this.foregroundDownloadFutureMap = DownloadFutureMap.create( sequentialControlExecutor, createCallbacksForForegroundService(context, foregroundDownloadServiceClassOptional)); } @Override public ListenableFuture download(DownloadRequest downloadRequest) { LogUtil.d("%s: download for Uri = %s", TAG, downloadRequest.destinationFileUri().toString()); ForegroundDownloadKey foregroundDownloadKey = ForegroundDownloadKey.ofSingleFile(downloadRequest.destinationFileUri()); return PropagatedFutures.transformAsync( getInProgressDownloadFuture(foregroundDownloadKey.toString()), (Optional> existingDownloadFuture) -> { // if there is the same on-going request, return that one. if (existingDownloadFuture.isPresent()) { return existingDownloadFuture.get(); } // 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.", TAG); } } // Create a ListenableFutureTask to delay starting the downloadFuture until we can add the // future to our map. ListenableFutureTask startTask = ListenableFutureTask.create(() -> null); ListenableFuture downloadFuture = PropagatedFutures.transformAsync( startTask, unused -> startDownload(downloadRequest), sequentialControlExecutor); PropagatedFutures.addCallback( downloadFuture, new FutureCallback() { @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()) { PropagatedFutures.addCallback( downloadRequest.listenerOptional().get().onComplete(), new FutureCallback() { @Override public void onSuccess(@NullableDecl Void result) { if (downloadMonitorOptional.isPresent()) { downloadMonitorOptional .get() .removeDownloadListener(downloadRequest.destinationFileUri()); } ListenableFuture unused = downloadFutureMap.remove(foregroundDownloadKey.toString()); } @Override public void onFailure(Throwable t) { LogUtil.e(t, "%s: Failed to run client onComplete", TAG); if (downloadMonitorOptional.isPresent()) { downloadMonitorOptional .get() .removeDownloadListener(downloadRequest.destinationFileUri()); } ListenableFuture unused = downloadFutureMap.remove(foregroundDownloadKey.toString()); } }, sequentialControlExecutor); } else { ListenableFuture unused = downloadFutureMap.remove(foregroundDownloadKey.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()); } } ListenableFuture unused = downloadFutureMap.remove(foregroundDownloadKey.toString()); } }, MoreExecutors.directExecutor()); return PropagatedFutures.transformAsync( downloadFutureMap.add(foregroundDownloadKey.toString(), downloadFuture), unused -> { // Now that the download future is added, start the task and return the future startTask.run(); return downloadFuture; }, sequentialControlExecutor); }, sequentialControlExecutor); } private ListenableFuture 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 immediateFailedFuture( DownloadException.builder() .setDownloadResultCode(DownloadResultCode.UNKNOWN_ERROR) .setCause(e) .build()); } } @Override public ListenableFuture downloadWithForegroundService(DownloadRequest downloadRequest) { LogUtil.d( "%s: downloadWithForegroundService for Uri = %s", TAG, downloadRequest.destinationFileUri().toString()); if (!downloadMonitorOptional.isPresent()) { return immediateFailedFuture( new IllegalStateException( "downloadWithForegroundService: DownloadMonitor is not provided!")); } if (!foregroundDownloadServiceClassOptional.isPresent()) { return immediateFailedFuture( new IllegalStateException( "downloadWithForegroundService: ForegroundDownloadService is not provided!")); } ForegroundDownloadKey foregroundDownloadKey = ForegroundDownloadKey.ofSingleFile(downloadRequest.destinationFileUri()); return PropagatedFutures.transformAsync( getInProgressDownloadFuture(foregroundDownloadKey.toString()), (Optional> existingDownloadFuture) -> { // if there is the same on-going request, return that one. if (existingDownloadFuture.isPresent()) { return existingDownloadFuture.get(); } // 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); DownloadListener downloadListenerWithNotification = createDownloadListenerWithNotification(downloadRequest); // The downloadMonitor will trigger the DownloadListener. downloadMonitorOptional .get() .addDownloadListener( downloadRequest.destinationFileUri(), downloadListenerWithNotification); // Create a ListenableFutureTask to delay starting the downloadFuture until we can add the // future to our map. ListenableFutureTask startTask = ListenableFutureTask.create(() -> null); ListenableFuture downloadFuture = PropagatedFutures.transformAsync( startTask, unused -> startDownload(downloadRequest), sequentialControlExecutor); PropagatedFutures.addCallback( downloadFuture, new FutureCallback() { @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. PropagatedFutures.addCallback( downloadListenerWithNotification.onComplete(), new FutureCallback() { @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()); return PropagatedFutures.transformAsync( foregroundDownloadFutureMap.add(foregroundDownloadKey.toString(), downloadFuture), unused -> { // Now that the download future is added, start the task and return the future startTask.run(); return downloadFuture; }, sequentialControlExecutor); }, sequentialControlExecutor); } // Assertion: foregroundDownloadService and downloadMonitor are present private DownloadListener createDownloadListenerWithNotification(DownloadRequest downloadRequest) { String networkPausedMessage = downloadRequest.downloadConstraints().requireUnmeteredNetwork() ? NotificationUtil.getDownloadPausedWifiMessage(context) : NotificationUtil.getDownloadPausedMessage(context); NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); NotificationCompat.Builder notification = NotificationUtil.createNotificationBuilder( context, downloadRequest.fileSizeBytes(), downloadRequest.notificationContentTitle(), downloadRequest.notificationContentTextOptional().or(downloadRequest.urlToDownload())); ForegroundDownloadKey foregroundDownloadKey = ForegroundDownloadKey.ofSingleFile(downloadRequest.destinationFileUri()); int notificationKey = NotificationUtil.notificationKeyForKey(foregroundDownloadKey.toString()); // Attach the Cancel action to the notification. NotificationUtil.createCancelAction( context, foregroundDownloadServiceClassOptional.get(), foregroundDownloadKey.toString(), notification, notificationKey); notificationManager.notify(notificationKey, notification.build()); return new DownloadListener() { @Override public void onProgress(long currentSize) { // TODO(b/229123693): return this future once DownloadListener has an async api. ListenableFuture unused = PropagatedFutures.transformAsync( foregroundDownloadFutureMap.containsKey(foregroundDownloadKey.toString()), futureInProgress -> { if (futureInProgress) { notification .setCategory(NotificationCompat.CATEGORY_PROGRESS) .setContentText( downloadRequest .notificationContentTextOptional() .or(downloadRequest.urlToDownload())) .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); } return immediateVoidFuture(); }, sequentialControlExecutor); } @Override public void onPausedForConnectivity() { // TODO(b/229123693): return this future once DownloadListener has an async api. ListenableFuture unused = PropagatedFutures.transformAsync( foregroundDownloadFutureMap.containsKey(foregroundDownloadKey.toString()), futureInProgress -> { if (futureInProgress) { notification .setCategory(NotificationCompat.CATEGORY_STATUS) .setContentText(networkPausedMessage) .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(); } return immediateVoidFuture(); }, sequentialControlExecutor); } @Override public ListenableFuture onComplete() { // We want to keep the Foreground Download Service alive until client's onComplete finishes. ListenableFuture clientOnCompleteFuture = downloadRequest.listenerOptional().isPresent() ? downloadRequest.listenerOptional().get().onComplete() : immediateVoidFuture(); // Logic to shutdown Foreground Download Service after the client's provided onComplete // finished return PropagatedFluentFuture.from(clientOnCompleteFuture) .transformAsync( unused -> { // onComplete succeeded, show a success message 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, foregroundDownloadKey.toString()); } return immediateVoidFuture(); }, sequentialControlExecutor) .catchingAsync( Exception.class, e -> { LogUtil.w( e, "%s: Delegate onComplete failed for uri: %s, showing failure notification.", TAG, downloadRequest.destinationFileUri()); notification.mActions.clear(); if (downloadRequest.showDownloadedNotification()) { 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()); } else { NotificationUtil.cancelNotificationForKey( context, foregroundDownloadKey.toString()); } return immediateVoidFuture(); }, sequentialControlExecutor) .transformAsync( unused -> { // After success or failure notification is shown, clean up downloadMonitorOptional .get() .removeDownloadListener(downloadRequest.destinationFileUri()); return foregroundDownloadFutureMap.remove(foregroundDownloadKey.toString()); }, sequentialControlExecutor); } @Override public void onFailure(Throwable t) { // TODO(b/229123693): return this future once DownloadListener has an async api. ListenableFuture unused = PropagatedFutures.submitAsync( () -> { // 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()); if (downloadRequest.listenerOptional().isPresent()) { downloadRequest.listenerOptional().get().onFailure(t); } downloadMonitorOptional .get() .removeDownloadListener(downloadRequest.destinationFileUri()); return foregroundDownloadFutureMap.remove(foregroundDownloadKey.toString()); }, sequentialControlExecutor); } }; } @Override public void cancelForegroundDownload(String downloadKey) { LogUtil.d("%s: CancelForegroundDownload for Uri = %s", TAG, downloadKey); ListenableFuture unused = PropagatedFutures.transformAsync( getInProgressDownloadFuture(downloadKey), downloadFuture -> { if (downloadFuture.isPresent()) { LogUtil.v( "%s: CancelForegroundDownload future found for key = %s, cancelling...", TAG, downloadKey); downloadFuture.get().cancel(false); } return immediateVoidFuture(); }, sequentialControlExecutor); } private ListenableFuture>> getInProgressDownloadFuture( String key) { return PropagatedFutures.transformAsync( foregroundDownloadFutureMap.containsKey(key), isInForeground -> isInForeground ? foregroundDownloadFutureMap.get(key) : downloadFutureMap.get(key), sequentialControlExecutor); } private static DownloadFutureMap.StateChangeCallbacks createCallbacksForForegroundService( Context context, Optional> foregroundDownloadServiceClassOptional) { return new DownloadFutureMap.StateChangeCallbacks() { @Override public void onAdd(String key, int newSize) { // Only start foreground service if this is the first future we are adding. if (newSize == 1 && foregroundDownloadServiceClassOptional.isPresent()) { NotificationUtil.startForegroundDownloadService( context, foregroundDownloadServiceClassOptional.get(), key); } } @Override public void onRemove(String key, int newSize) { // Only stop foreground service if there are no more futures remaining. if (newSize == 0 && foregroundDownloadServiceClassOptional.isPresent()) { NotificationUtil.stopForegroundDownloadService( context, foregroundDownloadServiceClassOptional.get(), key); } } }; } }