1 /*
2  * Copyright (C) 2018 The Android Open Source Project
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.android.tradefed.config;
17 
18 import com.android.annotations.VisibleForTesting;
19 import com.android.tradefed.build.BuildRetrievalError;
20 import com.android.tradefed.config.OptionSetter.OptionFieldsForName;
21 import com.android.tradefed.config.remote.IRemoteFileResolver;
22 import com.android.tradefed.device.ITestDevice;
23 import com.android.tradefed.invoker.logger.CurrentInvocation;
24 import com.android.tradefed.invoker.logger.CurrentInvocation.InvocationInfo;
25 import com.android.tradefed.invoker.logger.InvocationLocal;
26 import com.android.tradefed.log.LogUtil.CLog;
27 import com.android.tradefed.result.error.InfraErrorIdentifier;
28 import com.android.tradefed.util.FileUtil;
29 import com.android.tradefed.util.MultiMap;
30 import com.android.tradefed.util.ZipUtil;
31 import com.android.tradefed.util.ZipUtil2;
32 
33 import com.google.common.collect.ImmutableMap;
34 import com.google.common.collect.Maps;
35 
36 import java.io.File;
37 import java.io.IOException;
38 import java.lang.reflect.Field;
39 import java.net.URI;
40 import java.net.URISyntaxException;
41 import java.util.ArrayList;
42 import java.util.Collection;
43 import java.util.HashMap;
44 import java.util.HashSet;
45 import java.util.LinkedHashMap;
46 import java.util.List;
47 import java.util.Map;
48 import java.util.Map.Entry;
49 import java.util.ServiceLoader;
50 import java.util.Set;
51 import java.util.function.Supplier;
52 
53 import javax.annotation.Nullable;
54 import javax.annotation.concurrent.GuardedBy;
55 import javax.annotation.concurrent.ThreadSafe;
56 
57 /**
58  * Class that helps resolving path to remote files.
59  *
60  * <p>For example: gs://bucket/path/file.txt will be resolved by downloading the file from the GCS
61  * bucket.
62  *
63  * <p>New protocols should be added to META_INF/services.
64  */
65 public class DynamicRemoteFileResolver {
66 
67     // Query key for requesting to unzip a downloaded file automatically.
68     public static final String UNZIP_KEY = "unzip";
69     // Query key for requesting a download to be optional, so if it fails we don't replace it.
70     public static final String OPTIONAL_KEY = "optional";
71 
72     /**
73      * Loads file resolvers using a dedicated {@link ServiceFileResolverLoader} that is scoped to
74      * each invocation.
75      */
76     // TODO(hzalek): Store a DynamicRemoteFileResolver instance per invocation to avoid locals.
77     private static final FileResolverLoader DEFAULT_FILE_RESOLVER_LOADER =
78             new FileResolverLoader() {
79                 private final InvocationLocal<FileResolverLoader> mInvocationLoader =
80                         new InvocationLocal<FileResolverLoader>() {
81                             @Override
82                             protected FileResolverLoader initialValue() {
83                                 return new ServiceFileResolverLoader();
84                             }
85                         };
86 
87                 @Override
88                 public IRemoteFileResolver load(String scheme, Map<String, String> config) {
89                     return mInvocationLoader.get().load(scheme, config);
90                 }
91             };
92 
93     private final FileResolverLoader mFileResolverLoader;
94 
95     private Map<String, OptionFieldsForName> mOptionMap;
96     // Populated from {@link ICommandOptions#getDynamicDownloadArgs()}
97     private Map<String, String> mExtraArgs = new LinkedHashMap<>();
98     private ITestDevice mDevice;
99 
DynamicRemoteFileResolver()100     public DynamicRemoteFileResolver() {
101         this(DEFAULT_FILE_RESOLVER_LOADER);
102     }
103 
104     @VisibleForTesting
DynamicRemoteFileResolver(FileResolverLoader loader)105     public DynamicRemoteFileResolver(FileResolverLoader loader) {
106         this.mFileResolverLoader = loader;
107     }
108 
109     /** Sets the map of options coming from {@link OptionSetter} */
setOptionMap(Map<String, OptionFieldsForName> optionMap)110     public void setOptionMap(Map<String, OptionFieldsForName> optionMap) {
111         mOptionMap = optionMap;
112     }
113 
114     /** Sets the device under tests */
setDevice(ITestDevice device)115     public void setDevice(ITestDevice device) {
116         mDevice = device;
117     }
118 
119     /** Add extra args for the query. */
addExtraArgs(Map<String, String> extraArgs)120     public void addExtraArgs(Map<String, String> extraArgs) {
121         mExtraArgs.putAll(extraArgs);
122     }
123 
124     /**
125      * Runs through all the {@link File} option type and check if their path should be resolved.
126      *
127      * @return The list of {@link File} that was resolved that way.
128      * @throws BuildRetrievalError
129      */
validateRemoteFilePath()130     public final Set<File> validateRemoteFilePath() throws BuildRetrievalError {
131         Set<File> downloadedFiles = new HashSet<>();
132         try {
133             Map<Field, Object> fieldSeen = new HashMap<>();
134             for (Map.Entry<String, OptionFieldsForName> optionPair : mOptionMap.entrySet()) {
135                 final OptionFieldsForName optionFields = optionPair.getValue();
136                 for (Map.Entry<Object, Field> fieldEntry : optionFields) {
137 
138                     final Object obj = fieldEntry.getKey();
139                     final Field field = fieldEntry.getValue();
140                     final Option option = field.getAnnotation(Option.class);
141                     if (option == null) {
142                         continue;
143                     }
144                     // At this point, we know this is an option field; make sure it's set
145                     field.setAccessible(true);
146                     final Object value;
147                     try {
148                         value = field.get(obj);
149                         if (value == null) {
150                             continue;
151                         }
152                     } catch (IllegalAccessException e) {
153                         throw new BuildRetrievalError(
154                                 String.format("internal error: %s", e.getMessage()),
155                                 InfraErrorIdentifier.ARTIFACT_UNSUPPORTED_PATH);
156                     }
157 
158                     if (fieldSeen.get(field) != null && fieldSeen.get(field).equals(obj)) {
159                         continue;
160                     }
161                     // Keep track of the field set on each object
162                     fieldSeen.put(field, obj);
163 
164                     // The below contains unchecked casts that are mostly safe because we add/remove
165                     // items of a type already in the collection; assuming they're not instances of
166                     // some subclass of File. This is unlikely since we populate the items during
167                     // option injection. The possibility still exists that constructors of
168                     // initialized objects add objects that are instances of a File subclass. A
169                     // safer approach would be to have a custom type that can be deferenced to
170                     // access the resolved target file. This would also have the benefit of not
171                     // having to modify any user collections and preserve the ordering.
172 
173                     if (value instanceof File) {
174                         File consideredFile = (File) value;
175                         File downloadedFile = resolveRemoteFiles(consideredFile, option);
176                         if (downloadedFile != null) {
177                             downloadedFiles.add(downloadedFile);
178                             // Replace the field value
179                             try {
180                                 field.set(obj, downloadedFile);
181                             } catch (IllegalAccessException e) {
182                                 CLog.e(e);
183                                 throw new BuildRetrievalError(
184                                         String.format(
185                                                 "Failed to download %s due to '%s'",
186                                                 consideredFile.getPath(), e.getMessage()),
187                                         e);
188                             }
189                         }
190                     } else if (value instanceof Collection) {
191                         @SuppressWarnings("unchecked")  // Mostly-safe, see above comment.
192                         Collection<Object> c = (Collection<Object>) value;
193                         Collection<Object> copy = new ArrayList<>(c);
194                         for (Object o : copy) {
195                             if (o instanceof File) {
196                                 File consideredFile = (File) o;
197                                 File downloadedFile = resolveRemoteFiles(consideredFile, option);
198                                 if (downloadedFile != null) {
199                                     downloadedFiles.add(downloadedFile);
200                                     // TODO: See if order could be preserved.
201                                     c.remove(consideredFile);
202                                     c.add(downloadedFile);
203                                 }
204                             }
205                         }
206                     } else if (value instanceof Map) {
207                         @SuppressWarnings("unchecked")  // Mostly-safe, see above comment.
208                         Map<Object, Object> m = (Map<Object, Object>) value;
209                         Map<Object, Object> copy = new LinkedHashMap<>(m);
210                         for (Entry<Object, Object> entry : copy.entrySet()) {
211                             Object key = entry.getKey();
212                             Object val = entry.getValue();
213 
214                             Object finalKey = key;
215                             Object finalVal = val;
216                             if (key instanceof File) {
217                                 key = resolveRemoteFiles((File) key, option);
218                                 if (key != null) {
219                                     downloadedFiles.add((File) key);
220                                     finalKey = key;
221                                 }
222                             }
223                             if (val instanceof File) {
224                                 val = resolveRemoteFiles((File) val, option);
225                                 if (val != null) {
226                                     downloadedFiles.add((File) val);
227                                     finalVal = val;
228                                 }
229                             }
230 
231                             m.remove(entry.getKey());
232                             m.put(finalKey, finalVal);
233                         }
234                     } else if (value instanceof MultiMap) {
235                         @SuppressWarnings("unchecked")  // Mostly-safe, see above comment.
236                         MultiMap<Object, Object> m = (MultiMap<Object, Object>) value;
237                         synchronized (m) {
238                             MultiMap<Object, Object> copy = new MultiMap<>(m);
239                             for (Object key : copy.keySet()) {
240                                 List<Object> mapValues = copy.get(key);
241 
242                                 m.remove(key);
243                                 Object finalKey = key;
244                                 if (key instanceof File) {
245                                     key = resolveRemoteFiles((File) key, option);
246                                     if (key != null) {
247                                         downloadedFiles.add((File) key);
248                                         finalKey = key;
249                                     }
250                                 }
251                                 for (Object mapValue : mapValues) {
252                                     if (mapValue instanceof File) {
253                                         File f = resolveRemoteFiles((File) mapValue, option);
254                                         if (f != null) {
255                                             downloadedFiles.add(f);
256                                             mapValue = f;
257                                         }
258                                     }
259                                     m.put(finalKey, mapValue);
260                                 }
261                             }
262                         }
263                     }
264                 }
265             }
266         } catch (RuntimeException | BuildRetrievalError e) {
267             // Clean up the files before throwing
268             for (File f : downloadedFiles) {
269                 FileUtil.recursiveDelete(f);
270             }
271             throw e;
272         }
273         return downloadedFiles;
274     }
275 
276     /**
277      * Download the files matching given filters in a remote zip file.
278      *
279      * <p>A file inside the remote zip file is only downloaded if its path matches any of the
280      * include filters but not the exclude filters.
281      *
282      * @param destDir the file to place the downloaded contents into.
283      * @param remoteZipFilePath the remote path to the zip file to download, relative to an
284      *     implementation specific root.
285      * @param includeFilters a list of regex strings to download matching files. A file's path
286      *     matching any filter will be downloaded.
287      * @param excludeFilters a list of regex strings to skip downloading matching files. A file's
288      *     path matching any filter will not be downloaded.
289      * @throws BuildRetrievalError if files could not be downloaded.
290      */
resolvePartialDownloadZip( File destDir, String remoteZipFilePath, List<String> includeFilters, List<String> excludeFilters)291     public void resolvePartialDownloadZip(
292             File destDir,
293             String remoteZipFilePath,
294             List<String> includeFilters,
295             List<String> excludeFilters)
296             throws BuildRetrievalError {
297         Map<String, String> queryArgs;
298         String protocol;
299         try {
300             URI uri = new URI(remoteZipFilePath);
301             protocol = uri.getScheme();
302             queryArgs = parseQuery(uri.getQuery());
303         } catch (URISyntaxException e) {
304             throw new BuildRetrievalError(
305                     String.format(
306                             "Failed to parse the remote zip file path: %s", remoteZipFilePath),
307                     e);
308         }
309         queryArgs.put("partial_download_dir", destDir.getAbsolutePath());
310         if (includeFilters != null) {
311             queryArgs.put("include_filters", String.join(";", includeFilters));
312         }
313         if (excludeFilters != null) {
314             queryArgs.put("exclude_filters", String.join(";", excludeFilters));
315         }
316         // Downloaded individual files should be saved to destDir, return value is not needed.
317         try {
318             IRemoteFileResolver resolver = getResolver(protocol);
319             resolver.setPrimaryDevice(mDevice);
320             resolver.resolveRemoteFiles(new File(remoteZipFilePath), queryArgs);
321         } catch (BuildRetrievalError e) {
322             if (isOptional(queryArgs)) {
323                 CLog.d(
324                         "Failed to partially download '%s' but marked optional so skipping: %s",
325                         remoteZipFilePath, e.getMessage());
326                 return;
327             }
328 
329             throw e;
330         }
331     }
332 
getResolver(String protocol)333     private IRemoteFileResolver getResolver(String protocol) throws BuildRetrievalError {
334         try {
335         return mFileResolverLoader.load(protocol, mExtraArgs);
336         } catch (ResolverLoadingException e) {
337             throw new BuildRetrievalError(
338                     String.format("Could not load resolver for protocol %s", protocol), e);
339         }
340     }
341 
342     @VisibleForTesting
getGlobalConfig()343     IGlobalConfiguration getGlobalConfig() {
344         return GlobalConfiguration.getInstance();
345     }
346 
347     /**
348      * Utility that allows to check whether or not a file should be unzip and unzip it if required.
349      */
unzipIfRequired(File downloadedFile, Map<String, String> query)350     public static final File unzipIfRequired(File downloadedFile, Map<String, String> query)
351             throws IOException {
352         String unzipValue = query.get(UNZIP_KEY);
353         if (unzipValue != null && "true".equals(unzipValue.toLowerCase())) {
354             // File was requested to be unzipped.
355             if (ZipUtil.isZipFileValid(downloadedFile, false)) {
356                 File extractedDir =
357                         FileUtil.createTempDir(
358                                 FileUtil.getBaseName(downloadedFile.getName()),
359                                 CurrentInvocation.getInfo(InvocationInfo.WORK_FOLDER));
360                 ZipUtil2.extractZip(downloadedFile, extractedDir);
361                 FileUtil.deleteFile(downloadedFile);
362                 return extractedDir;
363             } else {
364                 CLog.w("%s was requested to be unzipped but is not a valid zip.", downloadedFile);
365             }
366         }
367         // Return the original file untouched
368         return downloadedFile;
369     }
370 
resolveRemoteFiles(File consideredFile, Option option)371     private File resolveRemoteFiles(File consideredFile, Option option) throws BuildRetrievalError {
372         File fileToResolve;
373         String path = consideredFile.getPath();
374         String protocol;
375         Map<String, String> query;
376         try {
377             URI uri = new URI(path);
378             protocol = uri.getScheme();
379             query = parseQuery(uri.getQuery());
380             fileToResolve = new File(protocol + ":" + uri.getPath());
381         } catch (URISyntaxException e) {
382             CLog.e(e);
383             return null;
384         }
385 
386         try {
387             IRemoteFileResolver resolver = getResolver(protocol);
388             if (resolver == null) {
389                 return null;
390             }
391 
392             CLog.d("Considering option '%s' with path: '%s' for download.", option.name(), path);
393             resolver.setPrimaryDevice(mDevice);
394             return resolver.resolveRemoteFiles(fileToResolve, query);
395         } catch (BuildRetrievalError e) {
396             if (isOptional(query)) {
397                 CLog.d(
398                         "Failed to resolve '%s' but marked optional so skipping: %s",
399                         fileToResolve, e.getMessage());
400                 return null;
401             }
402 
403             throw e;
404         }
405     }
406 
407     /**
408      * Parse a URL query style. Delimited by &, and map values represented by =. Example:
409      * ?key=value&key2=value2
410      */
parseQuery(String query)411     private Map<String, String> parseQuery(String query) {
412         Map<String, String> values = new HashMap<>();
413         if (query == null) {
414             return values;
415         }
416         for (String maps : query.split("&")) {
417             String[] keyVal = maps.split("=");
418             values.put(keyVal[0], keyVal[1]);
419         }
420         return values;
421     }
422 
423     /** Whether or not a link was requested as optional. */
isOptional(Map<String, String> query)424     private boolean isOptional(Map<String, String> query) {
425         String value = query.get(OPTIONAL_KEY);
426         if (value == null) {
427             return false;
428         }
429         return "true".equals(value.toLowerCase());
430     }
431 
432     /** Loads implementations of {@link IRemoteFileResolver}. */
433     @VisibleForTesting
434     public interface FileResolverLoader {
435         /**
436          * Loads a resolver that can handle the provided scheme.
437          *
438          * @param scheme the URI scheme that the loaded resolver is expected to handle.
439          * @param config a map of all dynamic resolver configuration key-value pairs specified by
440          *     the 'dynamic-resolver-args' TF command-line flag.
441          * @throws ResolverLoadingException if the resolver that handles the specified scheme cannot
442          *     be loaded and/or initialized.
443          */
444         @Nullable
load(String scheme, Map<String, String> config)445         IRemoteFileResolver load(String scheme, Map<String, String> config);
446     }
447 
448     /** Exception thrown if a resolver cannot be loaded or initialized. */
449     @VisibleForTesting
450     static final class ResolverLoadingException extends RuntimeException {
ResolverLoadingException(@ullable String message)451         public ResolverLoadingException(@Nullable String message) {
452             super(message);
453         }
454 
ResolverLoadingException(@ullable Throwable cause)455         public ResolverLoadingException(@Nullable Throwable cause) {
456             super(cause);
457         }
458 
ResolverLoadingException(@ullable String message, @Nullable Throwable cause)459         public ResolverLoadingException(@Nullable String message, @Nullable Throwable cause) {
460             super(message, cause);
461         }
462     }
463 
464     /**
465      * Loads and caches file resolvers using the service loading facility.
466      *
467      * <p>This implementation uses the service loading facility to find and cache available
468      * resolvers on the first call to {@code load}.
469      *
470      * <p>Any {@link Option}-annotated fields defined in loaded resolvers are initialized from the
471      * provided key-value pairs using the standard TF option-setting mechanism. Resolvers can define
472      * options that themselves require resolution as long as it causes no cycles during
473      * initialization.
474      *
475      * <p>Resolvers are loaded eagerly using ServiceLoader but have their options initialized only
476      * when first used. This avoids exceptions due to missing options in resolvers that are
477      * available on the class path but never used to load any files.
478      *
479      * <p>This implementation is thread-safe and ensures that any loaded resolvers are loaded at
480      * most once per instance.
481      */
482     @ThreadSafe
483     @VisibleForTesting
484     static final class ServiceFileResolverLoader implements FileResolverLoader {
485         // We need the indirection since in production we use the context class loader that is
486         // defined when loading and not the one at construction.
487         private final Supplier<ClassLoader> mClassLoaderSupplier;
488 
489         @GuardedBy("this")
490         private @Nullable LoaderState mLoaderState;
491 
ServiceFileResolverLoader()492         ServiceFileResolverLoader() {
493             mClassLoaderSupplier = () -> Thread.currentThread().getContextClassLoader();
494         }
495 
ServiceFileResolverLoader(ClassLoader classLoader)496         ServiceFileResolverLoader(ClassLoader classLoader) {
497             mClassLoaderSupplier = () -> classLoader;
498         }
499 
500         @Override
load(String scheme, Map<String, String> config)501         public synchronized IRemoteFileResolver load(String scheme, Map<String, String> config) {
502             if (mLoaderState != null) {
503                 return mLoaderState.getAndInit(scheme);
504             }
505 
506             // We use an intermediate map because the ImmutableMap builder throws if we add multiple
507             // entries with the same key. Note that we don't worry about setting any state that
508             // prevents this code from re-executing since failures loading service providers throws
509             // an Error which bubbles all the way to the top.
510             Map<String, IRemoteFileResolver> resolvers = new HashMap<>();
511             ServiceLoader<IRemoteFileResolver> serviceLoader =
512                     ServiceLoader.load(IRemoteFileResolver.class, mClassLoaderSupplier.get());
513 
514             for (IRemoteFileResolver resolver : serviceLoader) {
515                 resolvers.putIfAbsent(resolver.getSupportedProtocol(), resolver);
516             }
517 
518             mLoaderState = new LoaderState(resolvers, config);
519             return mLoaderState.getAndInit(scheme);
520         }
521 
522         /** Stores the state of loaded file resolvers. */
523         private static final class LoaderState {
524             private final ImmutableMap<String, String> mConfig;
525             private final ImmutableMap<String, ResolverState> mState;
526 
LoaderState(Map<String, IRemoteFileResolver> resolvers, Map<String, String> config)527             LoaderState(Map<String, IRemoteFileResolver> resolvers, Map<String, String> config) {
528                 this.mState =
529                         ImmutableMap.copyOf(
530                                 Maps.transformValues(resolvers, r -> new ResolverState(r)));
531                 this.mConfig = ImmutableMap.copyOf(config);
532             }
533 
534             /** Returns an initialized resolver instance for the specified scheme. */
535             @Nullable
getAndInit(String scheme)536             IRemoteFileResolver getAndInit(String scheme) {
537                 ResolverState state = mState.get(scheme);
538                 if (state == null) {
539                     return null;
540                 }
541 
542                 return state.getAndInit(this);
543             }
544 
resolve(IRemoteFileResolver resolver)545             void resolve(IRemoteFileResolver resolver)
546                     throws ConfigurationException, BuildRetrievalError {
547                 // The device isn't set when resolving dynamic options because we don't want to load
548                 // device-specific configuration when initializing pseudo-static resolvers that
549                 // could out-live a particular device.
550                 OptionSetter setter = new OptionSetter(resolver);
551 
552                 for (Map.Entry<String, String> e : mConfig.entrySet()) {
553                     String name = e.getKey();
554 
555                     // Note that we don't throw for options that don't exist.
556                     if (setter.fieldsForArgNoThrow(name) == null) {
557                         // TODO(hzalek): Consider throwing when the option doesn't exist and is
558                         // qualified using one of the option source's aliases.
559                         // option name uses one of
560                         // the option source's aliases
561                         continue;
562                     }
563 
564                     if (setter.isMapOption(name)) {
565                         throw new ConfigurationException("Map options are not supported: " + name);
566                     }
567 
568                     setter.setOptionValue(name, e.getValue());
569                 }
570 
571                 Collection<String> missingOptions = setter.getUnsetMandatoryOptions();
572                 if (!missingOptions.isEmpty()) {
573                     throw new ConfigurationException(
574                             String.format(
575                                     "Found missing mandatory options %s for resolver %s",
576                                     missingOptions, resolver.toString()));
577                 }
578 
579                 DynamicRemoteFileResolver dynamicResolver =
580                         new DynamicRemoteFileResolver((scheme, unused) -> getAndInit(scheme));
581                 dynamicResolver.addExtraArgs(mConfig);
582                 setter.validateRemoteFilePath(dynamicResolver);
583             }
584 
585             /** Stores the resolver and its initialization state. */
586             static final class ResolverState {
587                 final IRemoteFileResolver mResolver;
588 
589                 /**
590                  * The initialization state where {@code null} means never initialized, {@code
591                  * false} means started, and {@code true} means done.
592                  */
593                 @Nullable Boolean mDone;
594 
595                 /**
596                  * The exception thrown when initializing the resolver to ensure that we only do it
597                  * once.
598                  */
599                 @Nullable ResolverLoadingException mException;
600 
ResolverState(IRemoteFileResolver resolver)601                 ResolverState(IRemoteFileResolver resolver) {
602                     this.mResolver = resolver;
603                 }
604 
getAndInit(LoaderState context)605                 IRemoteFileResolver getAndInit(LoaderState context) {
606                     if (Boolean.TRUE.equals(mDone)) {
607                         return getOrThrow();
608                     }
609 
610                     if (Boolean.FALSE.equals(mDone)) {
611                         // No need to catch or store the exception since it gets thrown in the
612                         // recursive
613                         // call to the dynamic resolver as a BuildRetrievalError which we already
614                         // catch.
615                         throw new ResolverLoadingException(
616                                 "Cycle detected while initializing resolver options: "
617                                         + mResolver.toString());
618                     }
619 
620                     CLog.i("Initializing file resolver options: %s", mResolver);
621                     mDone = Boolean.FALSE;
622 
623                     try {
624                         context.resolve(mResolver);
625                     } catch (BuildRetrievalError | ConfigurationException e) {
626                         mException =
627                                 new ResolverLoadingException(
628                                         "Could not initialize resolver options: "
629                                                 + mResolver.toString(),
630                                         e);
631                         throw mException;
632                     } finally {
633                         mDone = Boolean.TRUE;
634                     }
635 
636                     return mResolver;
637                 }
638 
getOrThrow()639                 private IRemoteFileResolver getOrThrow() {
640                     if (mException != null) {
641                         throw mException;
642                     }
643                     return mResolver;
644                 }
645             }
646         }
647     }
648 }
649