• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.internal.downloader;
17 
18 import static com.google.common.util.concurrent.Futures.immediateFailedFuture;
19 import static com.google.common.util.concurrent.Futures.immediateFuture;
20 import static com.google.common.util.concurrent.Futures.immediateVoidFuture;
21 import static java.lang.Math.min;
22 
23 import android.content.Context;
24 import android.net.Uri;
25 import android.os.StatFs;
26 import android.util.Pair;
27 import androidx.annotation.VisibleForTesting;
28 import com.google.android.libraries.mobiledatadownload.DownloadException;
29 import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode;
30 import com.google.android.libraries.mobiledatadownload.FileSource;
31 import com.google.android.libraries.mobiledatadownload.Flags;
32 import com.google.android.libraries.mobiledatadownload.downloader.DownloadConstraints;
33 import com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest;
34 import com.google.android.libraries.mobiledatadownload.downloader.FileDownloader;
35 import com.google.android.libraries.mobiledatadownload.downloader.InlineDownloadParams;
36 import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
37 import com.google.android.libraries.mobiledatadownload.internal.ApplicationContext;
38 import com.google.android.libraries.mobiledatadownload.internal.annotations.SequentialControlExecutor;
39 import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
40 import com.google.android.libraries.mobiledatadownload.internal.logging.LoggingStateStore;
41 import com.google.android.libraries.mobiledatadownload.internal.util.DownloadFutureMap;
42 import com.google.android.libraries.mobiledatadownload.internal.util.FileGroupUtil;
43 import com.google.android.libraries.mobiledatadownload.monitor.DownloadProgressMonitor;
44 import com.google.android.libraries.mobiledatadownload.monitor.NetworkUsageMonitor;
45 import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFluentFuture;
46 import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFutures;
47 import com.google.common.base.Optional;
48 import com.google.common.base.Supplier;
49 import com.google.common.collect.ImmutableList;
50 import com.google.common.util.concurrent.AsyncFunction;
51 import com.google.common.util.concurrent.ListenableFuture;
52 import com.google.common.util.concurrent.ListenableFutureTask;
53 import com.google.mobiledatadownload.internal.MetadataProto.DownloadConditions;
54 import com.google.mobiledatadownload.internal.MetadataProto.DownloadConditions.DeviceNetworkPolicy;
55 import com.google.mobiledatadownload.internal.MetadataProto.ExtraHttpHeader;
56 import com.google.mobiledatadownload.internal.MetadataProto.GroupKey;
57 import java.io.IOException;
58 import java.util.HashMap;
59 import java.util.List;
60 import java.util.concurrent.Executor;
61 import javax.annotation.Nullable;
62 import javax.inject.Inject;
63 
64 /**
65  * Responsible for downloading files in MDD.
66  *
67  * <p>Provides methods to start and stop downloading a file. The stop method can be called if the
68  * file is no longer needed, or the file was already downloaded to the device.
69  *
70  * <p>This class supports both standard downloads (over https) or inline files (from a ByteString),
71  * using {@link #startDownloading} and {@link #startCopying}, respectively.
72  */
73 // TODO(b/129497867): Add tracking for on-going download to dedup download request from
74 // FileDownloader.
75 public class MddFileDownloader {
76 
77   private static final String TAG = "MddFileDownloader";
78 
79   // These should only be accessed through the getters and never directly.
80   private final Context context;
81   private final Supplier<FileDownloader> fileDownloaderSupplier;
82   private final SynchronousFileStorage fileStorage;
83   private final NetworkUsageMonitor networkUsageMonitor;
84   private final Optional<DownloadProgressMonitor> downloadMonitorOptional;
85   private final LoggingStateStore loggingStateStore;
86   private final Executor sequentialControlExecutor;
87   private final Flags flags;
88 
89   // Cache for all on-going downloads. This will be used to de-dup download requests.
90   // NOTE: all operations are internally sequenced through an ExecutionSequencer.
91   // NOTE: this map and fileUriToDownloadFutureMap are mutually exclusive and the use of
92   // one or the other is based on an MDD feature flag (enableFileDownloadDedupByFileKey). Once the
93   // flag is fully rolled out, this map will be used exclusively.
94   private final DownloadFutureMap<Void> downloadOrCopyFutureMap;
95 
96   // Cache for all on-going downloads. This will be used to de-dup download requests.
97   // NOTE: currently we assume that this map will only be accessed through the
98   // SequentialControlExecutor, so we don't need synchronization here.
99   // NOTE: this map and downloadOrCopyFutureMap are mutually exclusive and the use of
100   // one or the other is based on an MDD feature flag (enableFileDownloadDedupByFileKey). Once the
101   // flag is fully rolled out, this map will not be used.
102   @VisibleForTesting
103   final HashMap<Uri, ListenableFuture<Void>> fileUriToDownloadFutureMap = new HashMap<>();
104 
105   @Inject
MddFileDownloader( @pplicationContext Context context, Supplier<FileDownloader> fileDownloaderSupplier, SynchronousFileStorage fileStorage, NetworkUsageMonitor networkUsageMonitor, Optional<DownloadProgressMonitor> downloadMonitor, LoggingStateStore loggingStateStore, @SequentialControlExecutor Executor sequentialControlExecutor, Flags flags)106   public MddFileDownloader(
107       @ApplicationContext Context context,
108       Supplier<FileDownloader> fileDownloaderSupplier,
109       SynchronousFileStorage fileStorage,
110       NetworkUsageMonitor networkUsageMonitor,
111       Optional<DownloadProgressMonitor> downloadMonitor,
112       LoggingStateStore loggingStateStore,
113       @SequentialControlExecutor Executor sequentialControlExecutor,
114       Flags flags) {
115     this.context = context;
116     this.fileDownloaderSupplier = fileDownloaderSupplier;
117     this.fileStorage = fileStorage;
118     this.networkUsageMonitor = networkUsageMonitor;
119     this.downloadMonitorOptional = downloadMonitor;
120     this.loggingStateStore = loggingStateStore;
121     this.sequentialControlExecutor = sequentialControlExecutor;
122     this.flags = flags;
123     this.downloadOrCopyFutureMap = DownloadFutureMap.create(sequentialControlExecutor);
124   }
125 
126   /**
127    * Start downloading the file.
128    *
129    * @param fileKey key that identifies the shared file to download.
130    * @param groupKey GroupKey that contains the file to download.
131    * @param fileGroupVersionNumber version number of the group that contains the file to download.
132    * @param buildId build id of the group that contains the file to download.
133    * @param variantId variant id of the group that contains the file to download.
134    * @param fileUri - the File Uri to download the file at.
135    * @param urlToDownload - The url of the file to download.
136    * @param fileSize - the expected size of the file to download.
137    * @param downloadConditions - conditions under which this file should be downloaded.
138    * @param callback - callback called when the download either completes or fails.
139    * @param trafficTag - Tag for the network traffic to download this dataFile.
140    * @param extraHttpHeaders - Extra Headers for this request.
141    * @return - ListenableFuture representing the download result of a file.
142    */
startDownloading( String fileKey, GroupKey groupKey, int fileGroupVersionNumber, long buildId, String variantId, Uri fileUri, String urlToDownload, int fileSize, @Nullable DownloadConditions downloadConditions, DownloaderCallback callback, int trafficTag, List<ExtraHttpHeader> extraHttpHeaders)143   public ListenableFuture<Void> startDownloading(
144       String fileKey,
145       GroupKey groupKey,
146       int fileGroupVersionNumber,
147       long buildId,
148       String variantId,
149       Uri fileUri,
150       String urlToDownload,
151       int fileSize,
152       @Nullable DownloadConditions downloadConditions,
153       DownloaderCallback callback,
154       int trafficTag,
155       List<ExtraHttpHeader> extraHttpHeaders) {
156     return PropagatedFutures.transformAsync(
157         getInProgressFuture(fileKey, fileUri),
158         inProgressFuture -> {
159           if (inProgressFuture.isPresent()) {
160             return inProgressFuture.get();
161           }
162           return addCallbackAndRegister(
163               fileKey,
164               fileUri,
165               callback,
166               unused ->
167                   startDownloadingInternal(
168                       groupKey,
169                       fileGroupVersionNumber,
170                       buildId,
171                       variantId,
172                       fileUri,
173                       urlToDownload,
174                       fileSize,
175                       downloadConditions,
176                       trafficTag,
177                       extraHttpHeaders));
178         },
179         sequentialControlExecutor);
180   }
181 
182   /**
183    * Adds Callback to given Future and Registers future in in-progress cache.
184    *
185    * <p>Contains shared logic of connecting {@code callback} to {@code downloadOrCopyFunction} and
186    * registers future in the internal in-progress cache. This cache allows similar download/copy
187    * requests to be deduped instead of being performed twice.
188    *
189    * <p>NOTE: this method assumes the cache has already been checked for an in-progress operation
190    * and no in-progress operation exists for {@code fileUri}.
191    *
192    * @param fileKey key that identifies the shared file.
193    * @param fileUri the destination of the download/copy (used as Key in in-progress cache)
194    * @param callback the callback that should be run after the given download/copy future
195    * @param downloadOrCopyFunction an AsyncFunction that will perform the download/copy
196    * @return a ListenableFuture that calls the correct callback after {@code downloadOrCopyFuture
197    *     completes}
198    */
199   private ListenableFuture<Void> addCallbackAndRegister(
200       String fileKey,
201       Uri fileUri,
202       DownloaderCallback callback,
203       AsyncFunction<Void, Void> downloadOrCopyFunction) {
204     // Use ListenableFutureTask to create a future without starting it. This ensures we can
205     // successfully add our future to download/copy before the operation starts.
206     ListenableFutureTask<Void> startTask = ListenableFutureTask.create(() -> null);
207 
208     // Use transform & catching to ensure that we correctly chain everything.
209     PropagatedFluentFuture<Void> downloadOrCopyFuture =
210         PropagatedFluentFuture.from(startTask)
211             .transformAsync(downloadOrCopyFunction, sequentialControlExecutor)
212             .transformAsync(
213                 voidArg -> callback.onDownloadComplete(fileUri),
214                 sequentialControlExecutor /*Run callbacks on @SequentialControlExecutor*/)
215             .catchingAsync(
216                 Exception.class,
217                 e ->
218                     // Rethrow exception so the failure is passed back up the future chain.
219                     PropagatedFutures.transformAsync(
220                         callback.onDownloadFailed(asDownloadException(e)),
221                         voidArg -> {
222                           throw e;
223                         },
224                         sequentialControlExecutor),
225                 sequentialControlExecutor /*Run callbacks on @SequentialControlExecutor*/);
226 
227     // Add this future to the future map, then start startTask to unblock download/copy. The order
228     // ensures that the download/copy happens only if we were able to add the future to the map.
229     PropagatedFluentFuture<Void> transformedFuture =
230         PropagatedFluentFuture.from(addFutureToMap(downloadOrCopyFuture, fileKey, fileUri))
231             .transformAsync(
232                 unused -> {
233                   startTask.run();
234                   return downloadOrCopyFuture;
235                 },
236                 sequentialControlExecutor);
237 
238     // We want to remove the future from the cache when the transformedFuture finishes.
239     // However there may be a race condition and transformedFuture may finish before we put it into
240     // the cache.
241     // To prevent this race condition, we add a callback to transformedFuture to make sure the
242     // removal happens after the putting it in the map.
243     // A transform would not work since we want to run the removal even when the transform failed.
244     transformedFuture.addListener(
245         () -> {
246           ListenableFuture<Void> unused = removeFutureFromMap(fileKey, fileUri);
247         },
248         sequentialControlExecutor);
249 
250     return transformedFuture;
251   }
252 
253   private ListenableFuture<Void> addFutureToMap(
254       ListenableFuture<Void> downloadOrCopyFuture, String fileKey, Uri fileUri) {
255     if (!flags.enableFileDownloadDedupByFileKey()) {
256       fileUriToDownloadFutureMap.put(fileUri, downloadOrCopyFuture);
257       return immediateVoidFuture();
258     } else {
259       return downloadOrCopyFutureMap.add(fileKey, downloadOrCopyFuture);
260     }
261   }
262 
263   private ListenableFuture<Void> removeFutureFromMap(String fileKey, Uri fileUri) {
264     if (!flags.enableFileDownloadDedupByFileKey()) {
265       // Return the removed future if it exists, otherwise return immediately (Extra check added to
266       // satisfy nullness checker).
267       ListenableFuture<Void> removedFuture = fileUriToDownloadFutureMap.remove(fileUri);
268       if (removedFuture != null) {
269         return removedFuture;
270       }
271       return immediateVoidFuture();
272     } else {
273       return downloadOrCopyFutureMap.remove(fileKey);
274     }
275   }
276 
277   private ListenableFuture<Void> startDownloadingInternal(
278       GroupKey groupKey,
279       int fileGroupVersionNumber,
280       long buildId,
281       String variantId,
282       Uri fileUri,
283       String urlToDownload,
284       int fileSize,
285       @Nullable DownloadConditions downloadConditions,
286       int trafficTag,
287       List<ExtraHttpHeader> extraHttpHeaders) {
288     if (urlToDownload.startsWith("http")
289         && flags.downloaderEnforceHttps()
290         && !urlToDownload.startsWith("https")) {
291       LogUtil.e("%s: File url = %s is not secure", TAG, urlToDownload);
292       return immediateFailedFuture(
293           DownloadException.builder()
294               .setDownloadResultCode(DownloadResultCode.INSECURE_URL_ERROR)
295               .build());
296     }
297 
298     long currentFileSize = 0;
299     try {
300       currentFileSize = fileStorage.fileSize(fileUri);
301     } catch (IOException e) {
302       // Proceed with 0 as the current file size. It is only used for deciding whether we should
303       // download the file or not.
304     }
305 
306     try {
307       checkStorageConstraints(
308           context, urlToDownload, fileSize - currentFileSize, downloadConditions, flags);
309     } catch (DownloadException e) {
310       // Wrap exception in future to break future chain.
311       LogUtil.e("%s: Not enough space to download file %s", TAG, urlToDownload);
312       return immediateFailedFuture(e);
313     }
314 
315     if (flags.logNetworkStats()) {
316       networkUsageMonitor.monitorUri(
317           fileUri, groupKey, buildId, variantId, fileGroupVersionNumber, loggingStateStore);
318     } else {
319       LogUtil.w("%s: NetworkUsageMonitor is disabled", TAG);
320     }
321 
322     if (downloadMonitorOptional.isPresent()) {
323       downloadMonitorOptional.get().monitorUri(fileUri, groupKey.getGroupName());
324     }
325 
326     DownloadRequest.Builder downloadRequestBuilder =
327         DownloadRequest.newBuilder().setFileUri(fileUri).setUrlToDownload(urlToDownload);
328 
329     // TODO: consider to do this conversion upstream and we can pass in the
330     //  DownloadConstraints.
331     if (downloadConditions != null
332         && downloadConditions.getDeviceNetworkPolicy()
333             == DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK) {
334       downloadRequestBuilder.setDownloadConstraints(DownloadConstraints.NETWORK_CONNECTED);
335     } else {
336       downloadRequestBuilder.setDownloadConstraints(DownloadConstraints.NETWORK_UNMETERED);
337     }
338 
339     if (trafficTag > 0) {
340       downloadRequestBuilder.setTrafficTag(trafficTag);
341     }
342 
343     ImmutableList.Builder<Pair<String, String>> headerBuilder = ImmutableList.builder();
344     for (ExtraHttpHeader header : extraHttpHeaders) {
345       headerBuilder.add(Pair.create(header.getKey(), header.getValue()));
346     }
347 
348     downloadRequestBuilder.setExtraHttpHeaders(headerBuilder.build());
349 
350     return fileDownloaderSupplier.get().startDownloading(downloadRequestBuilder.build());
351   }
352 
353   /**
354    * Gets an in-progress future (if it exists), otherwise returns absent.
355    *
356    * <p>This method allows easier deduplication of file downloads/copies, by allowing callers to
357    * query against the internal download future map. This method is assumed to be called when a
358    * SharedFile state is DOWNLOAD_IN_PROGRESS.
359    *
360    * @param fileKey key that identifies the shared file.
361    * @param fileUri - the File Uri to download the file at.
362    * @return - ListenableFuture representing an in-progress download/copy for the given file.
363    */
364   public ListenableFuture<Optional<ListenableFuture<Void>>> getInProgressFuture(
365       String fileKey, Uri fileUri) {
366     if (!flags.enableFileDownloadDedupByFileKey()) {
367       return immediateFuture(Optional.fromNullable(fileUriToDownloadFutureMap.get(fileUri)));
368     } else {
369       return downloadOrCopyFutureMap.get(fileKey);
370     }
371   }
372 
373   /**
374    * Start Copying a file to internal storage
375    *
376    * @param fileKey key that identifies the shared file to copy.
377    * @param fileUri the File Uri where content should be copied.
378    * @param urlToDownload the url to copy, should be inlinefile: scheme.
379    * @param fileSize the size of the file to copy.
380    * @param downloadConditions conditions under which this file should be copied.
381    * @param downloaderCallback callback called when the copy either completes or fails.
382    * @param inlineFileSource Source of file content to copy.
383    * @return ListenableFuture representing the result of a file copy.
384    */
385   public ListenableFuture<Void> startCopying(
386       String fileKey,
387       Uri fileUri,
388       String urlToDownload,
389       int fileSize,
390       @Nullable DownloadConditions downloadConditions,
391       DownloaderCallback downloaderCallback,
392       FileSource inlineFileSource) {
393     return PropagatedFutures.transformAsync(
394         getInProgressFuture(fileKey, fileUri),
395         inProgressFuture -> {
396           if (inProgressFuture.isPresent()) {
397             return inProgressFuture.get();
398           }
399           return addCallbackAndRegister(
400               fileKey,
401               fileUri,
402               downloaderCallback,
403               unused ->
404                   startCopyingInternal(
405                       fileUri, urlToDownload, fileSize, downloadConditions, inlineFileSource));
406         },
407         sequentialControlExecutor);
408   }
409 
410   private ListenableFuture<Void> startCopyingInternal(
411       Uri fileUri,
412       String urlToCopy,
413       int fileSize,
414       @Nullable DownloadConditions downloadConditions,
415       FileSource inlineFileSource) {
416 
417     int finalFileSize = fileSize;
418     if (inlineFileSource.getKind().equals(FileSource.Kind.BYTESTRING)) {
419       int sourceFileSize = inlineFileSource.byteString().size();
420       if (sourceFileSize != fileSize) {
421         LogUtil.w(
422             "%s: expected file size (%d) does not match source file size (%d) -- using source file"
423                 + " size for storage check; file: %s",
424             TAG, fileSize, sourceFileSize, urlToCopy);
425         finalFileSize = sourceFileSize;
426       }
427     }
428 
429     try {
430       checkStorageConstraints(context, urlToCopy, finalFileSize, downloadConditions, flags);
431     } catch (DownloadException e) {
432       // Wrap exception in future to break future chain.
433       LogUtil.e("%s: Not enough space to download file %s", TAG, urlToCopy);
434       return immediateFailedFuture(e);
435     }
436 
437     // TODO(b/177361344): Only monitor file if download listener is supported
438 
439     DownloadRequest downloadRequest =
440         DownloadRequest.newBuilder()
441             .setUrlToDownload(urlToCopy)
442             .setFileUri(fileUri)
443             .setInlineDownloadParamsOptional(
444                 InlineDownloadParams.newBuilder().setInlineFileContent(inlineFileSource).build())
445             .build();
446 
447     // Use file download supplier to perform inline file download
448     return fileDownloaderSupplier.get().startDownloading(downloadRequest);
449   }
450 
451   /**
452    * Stop downloading the file.
453    *
454    * @param fileKey - key that identifies the file to stop downloading.
455    * @param fileUri - the File Uri of the file to stop downloading.
456    */
457   public void stopDownloading(String fileKey, Uri fileUri) {
458     ListenableFuture<Void> unused =
459         PropagatedFutures.transformAsync(
460             getInProgressFuture(fileKey, fileUri),
461             inProgressFuture -> {
462               if (inProgressFuture.isPresent()) {
463                 LogUtil.d("%s: Cancel download file %s", TAG, fileUri);
464                 inProgressFuture.get().cancel(/* mayInterruptIfRunning= */ true);
465                 return removeFutureFromMap(fileKey, fileUri);
466               } else {
467                 LogUtil.w("%s: stopDownloading on non-existent download", TAG);
468                 return immediateVoidFuture();
469               }
470             },
471             sequentialControlExecutor);
472   }
473 
474   /**
475    * Checks if storage constraints are enabled and if so, performs storage check.
476    *
477    * <p>If low storage enforcement is enabled, this method will check if a file with {@code
478    * bytesNeeded} can be stored on disk without hitting the storage threshold defined in {@code
479    * downloadConditions}.
480    *
481    * <p>If low storage enforcement is not enabled, this method is a no-op.
482    *
483    * <p>If {@code bytesNeeded} does hit the given storage threshold, a {@link DownloadException}
484    * will be thrown with the {@code DownloadResultCode.LOW_DISK_ERROR} error code.
485    *
486    * @param context Context in which storage should be checked
487    * @param bytesNeeded expected size of the file to store on disk
488    * @param downloadConditions conditions that contain the type of storage threshold to check
489    * @throws DownloadException when storing a file with the given size would hit the given storage
490    *     thresholds
491    */
492   private static void checkStorageConstraints(
493       Context context,
494       String url,
495       long bytesNeeded,
496       @Nullable DownloadConditions downloadConditions,
497       Flags flags)
498       throws DownloadException {
499     if (flags.enforceLowStorageBehavior()
500         && !shouldDownload(context, url, bytesNeeded, downloadConditions, flags)) {
501       throw DownloadException.builder()
502           .setDownloadResultCode(DownloadResultCode.LOW_DISK_ERROR)
503           .build();
504     }
505   }
506 
507   /**
508    * This calculates if the file should be downloaded. It checks that after download you have at
509    * least a certain fraction of free space or an absolute minimum space still available.
510    *
511    * <p>This is in parity with what the DownloadApi does- <internal>
512    */
513   private static boolean shouldDownload(
514       Context context,
515       String url,
516       long bytesNeeded,
517       @Nullable DownloadConditions downloadConditions,
518       Flags flags) {
519     // If we are using a placeholder (inline file + 0 byte size), bypass storage checks.
520     if (FileGroupUtil.isInlineFile(url) && bytesNeeded == 0L) {
521       return true;
522     }
523 
524     StatFs stats = new StatFs(context.getFilesDir().getAbsolutePath());
525 
526     long totalBytes = (long) stats.getBlockCount() * stats.getBlockSize();
527     long freeBytes = (long) stats.getAvailableBlocks() * stats.getBlockSize();
528 
529     double remainingBytesAfterDownload = freeBytes - bytesNeeded;
530 
531     double minBytes =
532         min(totalBytes * flags.fractionFreeSpaceAfterDownload(), flags.absFreeSpaceAfterDownload());
533 
534     if (downloadConditions != null) {
535       switch (downloadConditions.getDeviceStoragePolicy()) {
536         case BLOCK_DOWNLOAD_LOWER_THRESHOLD:
537           minBytes =
538               min(
539                   totalBytes * flags.fractionFreeSpaceAfterDownload(),
540                   flags.absFreeSpaceAfterDownloadLowStorageAllowed());
541           break;
542 
543         case EXTREMELY_LOW_THRESHOLD:
544           minBytes =
545               min(
546                   totalBytes * flags.fractionFreeSpaceAfterDownload(),
547                   flags.absFreeSpaceAfterDownloadExtremelyLowStorageAllowed());
548           break;
549         default:
550           // fallthrough.
551       }
552     }
553 
554     return remainingBytesAfterDownload > minBytes;
555   }
556 
557   /**
558    * Wraps throwable as DownloadException if it isn't one already.
559    *
560    * <p>This method doesn't check the incoming throwable besides the type and defaults the download
561    * result code to UNKNOWN_ERROR.
562    */
563   private static DownloadException asDownloadException(Throwable t) {
564     if (t instanceof DownloadException) {
565       return (DownloadException) t;
566     }
567 
568     return DownloadException.builder()
569         .setCause(t)
570         .setDownloadResultCode(DownloadResultCode.UNKNOWN_ERROR)
571         .build();
572   }
573 
574   /** Interface called by the downloader when download either completes or fails. */
575   public static interface DownloaderCallback {
576     /** Called on download complete. */
577     // TODO(b/123424546): Consider to drop fileUri.
578     ListenableFuture<Void> onDownloadComplete(Uri fileUri);
579 
580     /** Called on download failed. */
581     ListenableFuture<Void> onDownloadFailed(DownloadException exception);
582   }
583 }
584