1 /* 2 * Copyright 2022 Google LLC 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.google.android.libraries.mobiledatadownload.lite; 17 18 import static com.google.common.util.concurrent.Futures.immediateFailedFuture; 19 import static com.google.common.util.concurrent.Futures.immediateVoidFuture; 20 21 import android.content.Context; 22 import androidx.annotation.VisibleForTesting; 23 import androidx.core.app.NotificationCompat; 24 import androidx.core.app.NotificationManagerCompat; 25 import com.google.android.libraries.mobiledatadownload.DownloadException; 26 import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode; 27 import com.google.android.libraries.mobiledatadownload.downloader.FileDownloader; 28 import com.google.android.libraries.mobiledatadownload.foreground.ForegroundDownloadKey; 29 import com.google.android.libraries.mobiledatadownload.foreground.NotificationUtil; 30 import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil; 31 import com.google.android.libraries.mobiledatadownload.internal.util.DownloadFutureMap; 32 import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFluentFuture; 33 import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFutures; 34 import com.google.common.base.Optional; 35 import com.google.common.base.Supplier; 36 import com.google.common.util.concurrent.FutureCallback; 37 import com.google.common.util.concurrent.ListenableFuture; 38 import com.google.common.util.concurrent.ListenableFutureTask; 39 import com.google.common.util.concurrent.MoreExecutors; 40 import java.util.concurrent.Executor; 41 import org.checkerframework.checker.nullness.compatqual.NullableDecl; 42 43 final class DownloaderImpl implements Downloader { 44 private static final String TAG = "DownloaderImp"; 45 46 private final Context context; 47 private final Optional<Class<?>> foregroundDownloadServiceClassOptional; 48 // This executor will execute tasks sequentially. 49 private final Executor sequentialControlExecutor; 50 private final Optional<SingleFileDownloadProgressMonitor> downloadMonitorOptional; 51 private final Supplier<FileDownloader> fileDownloaderSupplier; 52 53 @VisibleForTesting final DownloadFutureMap<Void> downloadFutureMap; 54 @VisibleForTesting final DownloadFutureMap<Void> foregroundDownloadFutureMap; 55 DownloaderImpl( Context context, Optional<Class<?>> foregroundDownloadServiceClassOptional, Executor sequentialControlExecutor, Optional<SingleFileDownloadProgressMonitor> downloadMonitorOptional, Supplier<FileDownloader> fileDownloaderSupplier)56 DownloaderImpl( 57 Context context, 58 Optional<Class<?>> foregroundDownloadServiceClassOptional, 59 Executor sequentialControlExecutor, 60 Optional<SingleFileDownloadProgressMonitor> downloadMonitorOptional, 61 Supplier<FileDownloader> fileDownloaderSupplier) { 62 this.context = context; 63 this.sequentialControlExecutor = sequentialControlExecutor; 64 this.foregroundDownloadServiceClassOptional = foregroundDownloadServiceClassOptional; 65 this.downloadMonitorOptional = downloadMonitorOptional; 66 this.fileDownloaderSupplier = fileDownloaderSupplier; 67 this.downloadFutureMap = DownloadFutureMap.create(sequentialControlExecutor); 68 this.foregroundDownloadFutureMap = 69 DownloadFutureMap.create( 70 sequentialControlExecutor, 71 createCallbacksForForegroundService(context, foregroundDownloadServiceClassOptional)); 72 } 73 74 @Override download(DownloadRequest downloadRequest)75 public ListenableFuture<Void> download(DownloadRequest downloadRequest) { 76 LogUtil.d("%s: download for Uri = %s", TAG, downloadRequest.destinationFileUri().toString()); 77 ForegroundDownloadKey foregroundDownloadKey = 78 ForegroundDownloadKey.ofSingleFile(downloadRequest.destinationFileUri()); 79 80 return PropagatedFutures.transformAsync( 81 getInProgressDownloadFuture(foregroundDownloadKey.toString()), 82 (Optional<ListenableFuture<Void>> existingDownloadFuture) -> { 83 // if there is the same on-going request, return that one. 84 if (existingDownloadFuture.isPresent()) { 85 return existingDownloadFuture.get(); 86 } 87 88 // Register listener with monitor if present 89 if (downloadRequest.listenerOptional().isPresent()) { 90 if (downloadMonitorOptional.isPresent()) { 91 downloadMonitorOptional 92 .get() 93 .addDownloadListener( 94 downloadRequest.destinationFileUri(), 95 downloadRequest.listenerOptional().get()); 96 } else { 97 LogUtil.w( 98 "%s: download request included DownloadListener, but DownloadMonitor is not" 99 + " present! DownloadListener will only be invoked for complete/failure.", 100 TAG); 101 } 102 } 103 104 // Create a ListenableFutureTask to delay starting the downloadFuture until we can add the 105 // future to our map. 106 ListenableFutureTask<Void> startTask = ListenableFutureTask.create(() -> null); 107 ListenableFuture<Void> downloadFuture = 108 PropagatedFutures.transformAsync( 109 startTask, unused -> startDownload(downloadRequest), sequentialControlExecutor); 110 111 PropagatedFutures.addCallback( 112 downloadFuture, 113 new FutureCallback<Void>() { 114 @Override 115 public void onSuccess(Void result) { 116 // Currently the MobStore monitor does not support onSuccess so we have to add 117 // callback to the download future here. 118 119 // Remove download listener and remove download future from map after listener 120 // completes 121 if (downloadRequest.listenerOptional().isPresent()) { 122 PropagatedFutures.addCallback( 123 downloadRequest.listenerOptional().get().onComplete(), 124 new FutureCallback<Void>() { 125 @Override 126 public void onSuccess(@NullableDecl Void result) { 127 if (downloadMonitorOptional.isPresent()) { 128 downloadMonitorOptional 129 .get() 130 .removeDownloadListener(downloadRequest.destinationFileUri()); 131 } 132 ListenableFuture<Void> unused = 133 downloadFutureMap.remove(foregroundDownloadKey.toString()); 134 } 135 136 @Override 137 public void onFailure(Throwable t) { 138 LogUtil.e(t, "%s: Failed to run client onComplete", TAG); 139 if (downloadMonitorOptional.isPresent()) { 140 downloadMonitorOptional 141 .get() 142 .removeDownloadListener(downloadRequest.destinationFileUri()); 143 } 144 ListenableFuture<Void> unused = 145 downloadFutureMap.remove(foregroundDownloadKey.toString()); 146 } 147 }, 148 sequentialControlExecutor); 149 } else { 150 ListenableFuture<Void> unused = 151 downloadFutureMap.remove(foregroundDownloadKey.toString()); 152 } 153 } 154 155 @Override 156 public void onFailure(Throwable t) { 157 LogUtil.e(t, "%s: Download Future failed", TAG); 158 159 // Currently the MobStore monitor does not support onFailure so we have to add 160 // callback to the download future here. 161 if (downloadRequest.listenerOptional().isPresent()) { 162 downloadRequest.listenerOptional().get().onFailure(t); 163 if (downloadMonitorOptional.isPresent()) { 164 downloadMonitorOptional 165 .get() 166 .removeDownloadListener(downloadRequest.destinationFileUri()); 167 } 168 } 169 ListenableFuture<Void> unused = 170 downloadFutureMap.remove(foregroundDownloadKey.toString()); 171 } 172 }, 173 MoreExecutors.directExecutor()); 174 175 return PropagatedFutures.transformAsync( 176 downloadFutureMap.add(foregroundDownloadKey.toString(), downloadFuture), 177 unused -> { 178 // Now that the download future is added, start the task and return the future 179 startTask.run(); 180 return downloadFuture; 181 }, 182 sequentialControlExecutor); 183 }, 184 sequentialControlExecutor); 185 } 186 187 private ListenableFuture<Void> startDownload(DownloadRequest downloadRequest) { 188 // Translate from MDDLite DownloadRequest to MDDDownloader DownloadRequest. 189 com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest 190 fileDownloaderRequest = 191 com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest.newBuilder() 192 .setFileUri(downloadRequest.destinationFileUri()) 193 .setDownloadConstraints(downloadRequest.downloadConstraints()) 194 .setUrlToDownload(downloadRequest.urlToDownload()) 195 .setExtraHttpHeaders(downloadRequest.extraHttpHeaders()) 196 .setTrafficTag(downloadRequest.trafficTag()) 197 .build(); 198 try { 199 return fileDownloaderSupplier.get().startDownloading(fileDownloaderRequest); 200 } catch (RuntimeException e) { 201 // Catch any unchecked exceptions that prevented the download from starting. 202 return immediateFailedFuture( 203 DownloadException.builder() 204 .setDownloadResultCode(DownloadResultCode.UNKNOWN_ERROR) 205 .setCause(e) 206 .build()); 207 } 208 } 209 210 @Override 211 public ListenableFuture<Void> downloadWithForegroundService(DownloadRequest downloadRequest) { 212 LogUtil.d( 213 "%s: downloadWithForegroundService for Uri = %s", 214 TAG, downloadRequest.destinationFileUri().toString()); 215 if (!downloadMonitorOptional.isPresent()) { 216 return immediateFailedFuture( 217 new IllegalStateException( 218 "downloadWithForegroundService: DownloadMonitor is not provided!")); 219 } 220 if (!foregroundDownloadServiceClassOptional.isPresent()) { 221 return immediateFailedFuture( 222 new IllegalStateException( 223 "downloadWithForegroundService: ForegroundDownloadService is not provided!")); 224 } 225 226 ForegroundDownloadKey foregroundDownloadKey = 227 ForegroundDownloadKey.ofSingleFile(downloadRequest.destinationFileUri()); 228 229 return PropagatedFutures.transformAsync( 230 getInProgressDownloadFuture(foregroundDownloadKey.toString()), 231 (Optional<ListenableFuture<Void>> existingDownloadFuture) -> { 232 // if there is the same on-going request, return that one. 233 if (existingDownloadFuture.isPresent()) { 234 return existingDownloadFuture.get(); 235 } 236 237 // It's OK to recreate the NotificationChannel since it can also be used to restore a 238 // deleted channel and to update an existing channel's name, description, group, and/or 239 // importance. 240 NotificationUtil.createNotificationChannel(context); 241 242 DownloadListener downloadListenerWithNotification = 243 createDownloadListenerWithNotification(downloadRequest); 244 245 // The downloadMonitor will trigger the DownloadListener. 246 downloadMonitorOptional 247 .get() 248 .addDownloadListener( 249 downloadRequest.destinationFileUri(), downloadListenerWithNotification); 250 251 // Create a ListenableFutureTask to delay starting the downloadFuture until we can add the 252 // future to our map. 253 ListenableFutureTask<Void> startTask = ListenableFutureTask.create(() -> null); 254 ListenableFuture<Void> downloadFuture = 255 PropagatedFutures.transformAsync( 256 startTask, unused -> startDownload(downloadRequest), sequentialControlExecutor); 257 258 PropagatedFutures.addCallback( 259 downloadFuture, 260 new FutureCallback<Void>() { 261 @Override 262 public void onSuccess(Void result) { 263 // Currently the MobStore monitor does not support onSuccess so we have to add 264 // callback to the download future here. 265 266 PropagatedFutures.addCallback( 267 downloadListenerWithNotification.onComplete(), 268 new FutureCallback<Void>() { 269 @Override 270 public void onSuccess(@NullableDecl Void result) {} 271 272 @Override 273 public void onFailure(Throwable t) { 274 LogUtil.e(t, "%s: Failed to run client onComplete", TAG); 275 } 276 }, 277 sequentialControlExecutor); 278 } 279 280 @Override 281 public void onFailure(Throwable t) { 282 // Currently the MobStore monitor does not support onFailure so we have to add 283 // callback to the download future here. 284 LogUtil.e(t, "%s: Download Future failed", TAG); 285 downloadListenerWithNotification.onFailure(t); 286 } 287 }, 288 MoreExecutors.directExecutor()); 289 290 return PropagatedFutures.transformAsync( 291 foregroundDownloadFutureMap.add(foregroundDownloadKey.toString(), downloadFuture), 292 unused -> { 293 // Now that the download future is added, start the task and return the future 294 startTask.run(); 295 return downloadFuture; 296 }, 297 sequentialControlExecutor); 298 }, 299 sequentialControlExecutor); 300 } 301 302 // Assertion: foregroundDownloadService and downloadMonitor are present 303 private DownloadListener createDownloadListenerWithNotification(DownloadRequest downloadRequest) { 304 String networkPausedMessage = 305 downloadRequest.downloadConstraints().requireUnmeteredNetwork() 306 ? NotificationUtil.getDownloadPausedWifiMessage(context) 307 : NotificationUtil.getDownloadPausedMessage(context); 308 309 NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); 310 NotificationCompat.Builder notification = 311 NotificationUtil.createNotificationBuilder( 312 context, 313 downloadRequest.fileSizeBytes(), 314 downloadRequest.notificationContentTitle(), 315 downloadRequest.notificationContentTextOptional().or(downloadRequest.urlToDownload())); 316 317 ForegroundDownloadKey foregroundDownloadKey = 318 ForegroundDownloadKey.ofSingleFile(downloadRequest.destinationFileUri()); 319 320 int notificationKey = NotificationUtil.notificationKeyForKey(foregroundDownloadKey.toString()); 321 322 // Attach the Cancel action to the notification. 323 NotificationUtil.createCancelAction( 324 context, 325 foregroundDownloadServiceClassOptional.get(), 326 foregroundDownloadKey.toString(), 327 notification, 328 notificationKey); 329 notificationManager.notify(notificationKey, notification.build()); 330 331 return new DownloadListener() { 332 @Override 333 public void onProgress(long currentSize) { 334 // TODO(b/229123693): return this future once DownloadListener has an async api. 335 ListenableFuture<?> unused = 336 PropagatedFutures.transformAsync( 337 foregroundDownloadFutureMap.containsKey(foregroundDownloadKey.toString()), 338 futureInProgress -> { 339 if (futureInProgress) { 340 notification 341 .setCategory(NotificationCompat.CATEGORY_PROGRESS) 342 .setContentText( 343 downloadRequest 344 .notificationContentTextOptional() 345 .or(downloadRequest.urlToDownload())) 346 .setSmallIcon(android.R.drawable.stat_sys_download) 347 .setProgress( 348 downloadRequest.fileSizeBytes(), 349 (int) currentSize, 350 /* indeterminate= */ downloadRequest.fileSizeBytes() <= 0); 351 notificationManager.notify(notificationKey, notification.build()); 352 } 353 if (downloadRequest.listenerOptional().isPresent()) { 354 downloadRequest.listenerOptional().get().onProgress(currentSize); 355 } 356 return immediateVoidFuture(); 357 }, 358 sequentialControlExecutor); 359 } 360 361 @Override 362 public void onPausedForConnectivity() { 363 // TODO(b/229123693): return this future once DownloadListener has an async api. 364 ListenableFuture<?> unused = 365 PropagatedFutures.transformAsync( 366 foregroundDownloadFutureMap.containsKey(foregroundDownloadKey.toString()), 367 futureInProgress -> { 368 if (futureInProgress) { 369 notification 370 .setCategory(NotificationCompat.CATEGORY_STATUS) 371 .setContentText(networkPausedMessage) 372 .setSmallIcon(android.R.drawable.stat_sys_download) 373 .setOngoing(true) 374 // hide progress bar. 375 .setProgress(0, 0, false); 376 notificationManager.notify(notificationKey, notification.build()); 377 } 378 if (downloadRequest.listenerOptional().isPresent()) { 379 downloadRequest.listenerOptional().get().onPausedForConnectivity(); 380 } 381 return immediateVoidFuture(); 382 }, 383 sequentialControlExecutor); 384 } 385 386 @Override 387 public ListenableFuture<Void> onComplete() { 388 // We want to keep the Foreground Download Service alive until client's onComplete finishes. 389 ListenableFuture<Void> clientOnCompleteFuture = 390 downloadRequest.listenerOptional().isPresent() 391 ? downloadRequest.listenerOptional().get().onComplete() 392 : immediateVoidFuture(); 393 394 // Logic to shutdown Foreground Download Service after the client's provided onComplete 395 // finished 396 return PropagatedFluentFuture.from(clientOnCompleteFuture) 397 .transformAsync( 398 unused -> { 399 // onComplete succeeded, show a success message 400 notification.mActions.clear(); 401 402 if (downloadRequest.showDownloadedNotification()) { 403 notification 404 .setCategory(NotificationCompat.CATEGORY_STATUS) 405 .setContentText(NotificationUtil.getDownloadSuccessMessage(context)) 406 .setOngoing(false) 407 .setSmallIcon(android.R.drawable.stat_sys_download_done) 408 // hide progress bar. 409 .setProgress(0, 0, false); 410 411 notificationManager.notify(notificationKey, notification.build()); 412 } else { 413 NotificationUtil.cancelNotificationForKey( 414 context, foregroundDownloadKey.toString()); 415 } 416 return immediateVoidFuture(); 417 }, 418 sequentialControlExecutor) 419 .catchingAsync( 420 Exception.class, 421 e -> { 422 LogUtil.w( 423 e, 424 "%s: Delegate onComplete failed for uri: %s, showing failure notification.", 425 TAG, 426 downloadRequest.destinationFileUri()); 427 notification.mActions.clear(); 428 429 if (downloadRequest.showDownloadedNotification()) { 430 notification 431 .setCategory(NotificationCompat.CATEGORY_STATUS) 432 .setContentText(NotificationUtil.getDownloadFailedMessage(context)) 433 .setOngoing(false) 434 .setSmallIcon(android.R.drawable.stat_sys_warning) 435 // hide progress bar. 436 .setProgress(0, 0, false); 437 438 notificationManager.notify(notificationKey, notification.build()); 439 } else { 440 NotificationUtil.cancelNotificationForKey( 441 context, downloadRequest.destinationFileUri().toString()); 442 } 443 444 return immediateVoidFuture(); 445 }, 446 sequentialControlExecutor) 447 .transformAsync( 448 unused -> { 449 // After success or failure notification is shown, clean up 450 downloadMonitorOptional 451 .get() 452 .removeDownloadListener(downloadRequest.destinationFileUri()); 453 454 return foregroundDownloadFutureMap.remove(foregroundDownloadKey.toString()); 455 }, 456 sequentialControlExecutor); 457 } 458 459 @Override 460 public void onFailure(Throwable t) { 461 // TODO(b/229123693): return this future once DownloadListener has an async api. 462 ListenableFuture<?> unused = 463 PropagatedFutures.submitAsync( 464 () -> { 465 // Clear the notification action. 466 notification.mActions.clear(); 467 468 // Show download failed in notification. 469 notification 470 .setCategory(NotificationCompat.CATEGORY_STATUS) 471 .setContentText(NotificationUtil.getDownloadFailedMessage(context)) 472 .setOngoing(false) 473 .setSmallIcon(android.R.drawable.stat_sys_warning) 474 // hide progress bar. 475 .setProgress(0, 0, false); 476 477 notificationManager.notify(notificationKey, notification.build()); 478 479 if (downloadRequest.listenerOptional().isPresent()) { 480 downloadRequest.listenerOptional().get().onFailure(t); 481 } 482 downloadMonitorOptional 483 .get() 484 .removeDownloadListener(downloadRequest.destinationFileUri()); 485 486 return foregroundDownloadFutureMap.remove(foregroundDownloadKey.toString()); 487 }, 488 sequentialControlExecutor); 489 } 490 }; 491 } 492 493 @Override 494 public void cancelForegroundDownload(String downloadKey) { 495 LogUtil.d("%s: CancelForegroundDownload for Uri = %s", TAG, downloadKey); 496 ListenableFuture<?> unused = 497 PropagatedFutures.transformAsync( 498 getInProgressDownloadFuture(downloadKey), 499 downloadFuture -> { 500 if (downloadFuture.isPresent()) { 501 LogUtil.v( 502 "%s: CancelForegroundDownload future found for key = %s, cancelling...", 503 TAG, downloadKey); 504 downloadFuture.get().cancel(false); 505 } 506 return immediateVoidFuture(); 507 }, 508 sequentialControlExecutor); 509 } 510 511 private ListenableFuture<Optional<ListenableFuture<Void>>> getInProgressDownloadFuture( 512 String key) { 513 return PropagatedFutures.transformAsync( 514 foregroundDownloadFutureMap.containsKey(key), 515 isInForeground -> 516 isInForeground ? foregroundDownloadFutureMap.get(key) : downloadFutureMap.get(key), 517 sequentialControlExecutor); 518 } 519 520 private static DownloadFutureMap.StateChangeCallbacks createCallbacksForForegroundService( 521 Context context, Optional<Class<?>> foregroundDownloadServiceClassOptional) { 522 return new DownloadFutureMap.StateChangeCallbacks() { 523 @Override 524 public void onAdd(String key, int newSize) { 525 // Only start foreground service if this is the first future we are adding. 526 if (newSize == 1 && foregroundDownloadServiceClassOptional.isPresent()) { 527 NotificationUtil.startForegroundDownloadService( 528 context, foregroundDownloadServiceClassOptional.get(), key); 529 } 530 } 531 532 @Override 533 public void onRemove(String key, int newSize) { 534 // Only stop foreground service if there are no more futures remaining. 535 if (newSize == 0 && foregroundDownloadServiceClassOptional.isPresent()) { 536 NotificationUtil.stopForegroundDownloadService( 537 context, foregroundDownloadServiceClassOptional.get(), key); 538 } 539 } 540 }; 541 } 542 } 543