• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2012 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 
17 package com.android.sdklib.internal.repository;
18 
19 import com.android.annotations.Nullable;
20 import com.android.annotations.VisibleForTesting;
21 import com.android.annotations.VisibleForTesting.Visibility;
22 import com.android.prefs.AndroidLocation;
23 import com.android.prefs.AndroidLocation.AndroidLocationException;
24 import com.android.sdklib.SdkConstants;
25 import com.android.sdklib.internal.repository.UrlOpener.CanceledByUserException;
26 import com.android.util.Pair;
27 
28 import org.apache.http.Header;
29 import org.apache.http.HttpHeaders;
30 import org.apache.http.HttpResponse;
31 import org.apache.http.HttpStatus;
32 import org.apache.http.message.BasicHeader;
33 
34 import java.io.ByteArrayInputStream;
35 import java.io.File;
36 import java.io.FileInputStream;
37 import java.io.FileNotFoundException;
38 import java.io.FileOutputStream;
39 import java.io.IOException;
40 import java.io.InputStream;
41 import java.io.OutputStream;
42 import java.util.ArrayList;
43 import java.util.Arrays;
44 import java.util.List;
45 import java.util.Locale;
46 import java.util.Properties;
47 import java.util.concurrent.atomic.AtomicInteger;
48 
49 
50 /**
51  * A simple cache for the XML resources handled by the SDK Manager.
52  * <p/>
53  * Callers should use {@link #openDirectUrl(String, ITaskMonitor)} to download "large files"
54  * that should not be cached (like actual installation packages which are several MBs big)
55  * and call {@link #openCachedUrl(String, ITaskMonitor)} to download small XML files.
56  * <p/>
57  * The cache can work in 3 different strategies (direct is a pass-through, fresh-cache is the
58  * default and tries to update resources if they are older than 10 minutes by respecting
59  * either ETag or Last-Modified, and finally server-cache is a strategy to always serve
60  * cached entries if present.)
61  */
62 public class DownloadCache {
63 
64     /*
65      * HTTP/1.1 references:
66      * - Possible headers:
67      *     http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html
68      * - Rules about conditional requests:
69      *     http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.3.4
70      * - Error codes:
71      *     http://www.w3.org/Protocols/rfc2616/rfc2616-sec6.html#sec6.1.1
72      */
73 
74     private static final boolean DEBUG = System.getenv("SDKMAN_DEBUG_CACHE") != null;
75 
76     /** Key for the Status-Code in the info properties. */
77     private static final String KEY_STATUS_CODE = "Status-Code";        //$NON-NLS-1$
78     /** Key for the URL in the info properties. */
79     private static final String KEY_URL = "URL";                        //$NON-NLS-1$
80 
81     /** Prefix of binary files stored in the {@link SdkConstants#FD_CACHE} directory. */
82     private final static String BIN_FILE_PREFIX = "sdkbin-";            //$NON-NLS-1$
83     /** Prefix of meta info files stored in the {@link SdkConstants#FD_CACHE} directory. */
84     private final static String INFO_FILE_PREFIX = "sdkinf-";           //$NON-NLS-1$
85 
86     /**
87      * Minimum time before we consider a cached entry is potentially stale.
88      * Expressed in milliseconds.
89      * <p/>
90      * When using the {@link Strategy#FRESH_CACHE}, the cache will not try to refresh
91      * a cached file if it's has been saved more recently than this time.
92      * When using the direct mode or the serve mode, the cache either doesn't serve
93      * cached files or always serves caches files so this expiration delay is not used.
94      * <p/>
95      * Default is 10 minutes.
96      * <p/>
97      * TODO: change for a dynamic preference later.
98      */
99     private final static long MIN_TIME_EXPIRED_MS =  10*60*1000;
100     /**
101      * Maximum time before we consider a cache entry to be stale.
102      * Expressed in milliseconds.
103      * <p/>
104      * When using the {@link Strategy#FRESH_CACHE}, entries that have no ETag
105      * or Last-Modified will be refreshed if their file timestamp is older than
106      * this value.
107      * <p/>
108      * Default is 4 hours.
109      * <p/>
110      * TODO: change for a dynamic preference later.
111      */
112     private final static long MAX_TIME_EXPIRED_MS = 4*60*60*1000;
113 
114     /**
115      * The maximum file size we'll cache for "small" files.
116      * 640KB is more than enough and is already a stretch since these are read in memory.
117      * (The actual typical size of the files handled here is in the 4-64KB range.)
118      */
119     private final static int MAX_SMALL_FILE_SIZE = 640 * 1024;
120 
121     /**
122      * HTTP Headers that are saved in an info file.
123      * For HTTP/1.1 header names, see http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html
124      */
125     private final static String[] INFO_HTTP_HEADERS = {
126         HttpHeaders.LAST_MODIFIED,
127         HttpHeaders.ETAG,
128         HttpHeaders.CONTENT_LENGTH,
129         HttpHeaders.DATE
130     };
131 
132     private final Strategy mStrategy;
133     private final File mCacheRoot;
134 
135     public enum Strategy {
136         /**
137          * If the files are available in the cache, serve them as-is, otherwise
138          * download them and return the cached version. No expiration or refresh
139          * is attempted if a file is in the cache.
140          */
141         SERVE_CACHE,
142         /**
143          * If the files are available in the cache, check if there's an update
144          * (either using an e-tag check or comparing to the default time expiration).
145          * If files have expired or are not in the cache then download them and return
146          * the cached version.
147          */
148         FRESH_CACHE,
149         /**
150          * Disables caching. URLs are always downloaded and returned directly.
151          * Downloaded streams aren't cached locally.
152          */
153         DIRECT
154     }
155 
156     /** Creates a default instance of the URL cache */
DownloadCache(Strategy strategy)157     public DownloadCache(Strategy strategy) {
158         mCacheRoot = initCacheRoot();
159         mStrategy = mCacheRoot == null ? Strategy.DIRECT : strategy;
160     }
161 
getStrategy()162     public Strategy getStrategy() {
163         return mStrategy;
164     }
165 
166     /**
167      * Returns the directory to be used as a cache.
168      * Creates it if necessary.
169      * Makes it possible to disable or override the cache location in unit tests.
170      *
171      * @return An existing directory to use as a cache root dir,
172      *   or null in case of error in which case the cache will be disabled.
173      */
174     @VisibleForTesting(visibility=Visibility.PRIVATE)
initCacheRoot()175     protected File initCacheRoot() {
176         try {
177             File root = new File(AndroidLocation.getFolder());
178             root = new File(root, SdkConstants.FD_CACHE);
179             if (!root.exists()) {
180                 root.mkdirs();
181             }
182             return root;
183         } catch (AndroidLocationException e) {
184             // No root? Disable the cache.
185             return null;
186         }
187     }
188 
189     /**
190      * Does a direct download of the given URL using {@link UrlOpener}.
191      * This does not check the download cache and does not attempt to cache the file.
192      * Instead the HttpClient library returns a progressive download stream.
193      * <p/>
194      * For details on realm authentication and user/password handling,
195      * check the underlying {@link UrlOpener#openUrl(String, ITaskMonitor, Header[])}
196      * documentation.
197      *
198      * @param urlString the URL string to be opened.
199      * @param monitor {@link ITaskMonitor} which is related to this URL
200      *            fetching.
201      * @return Returns an {@link InputStream} holding the URL content.
202      * @throws IOException Exception thrown when there are problems retrieving
203      *             the URL or its content.
204      * @throws CanceledByUserException Exception thrown if the user cancels the
205      *              authentication dialog.
206      */
openDirectUrl(String urlString, ITaskMonitor monitor)207     public InputStream openDirectUrl(String urlString, ITaskMonitor monitor)
208             throws IOException, CanceledByUserException {
209         if (DEBUG) {
210             System.out.println(String.format("%s : Direct download", urlString)); //$NON-NLS-1$
211         }
212         Pair<InputStream, HttpResponse> result =
213             UrlOpener.openUrl(urlString, monitor, null /*headers*/);
214         return result.getFirst();
215     }
216 
217     /**
218      * Downloads a small file, typically XML manifests.
219      * The current {@link Strategy} governs whether the file is served as-is
220      * from the cache, potentially updated first or directly downloaded.
221      * <p/>
222      * For large downloads (e.g. installable archives) please do not invoke the
223      * cache and instead use the {@link #openDirectUrl(String, ITaskMonitor)}
224      * method.
225      * <p/>
226      * For details on realm authentication and user/password handling,
227      * check the underlying {@link UrlOpener#openUrl(String, ITaskMonitor, Header[])}
228      * documentation.
229      *
230      * @param urlString the URL string to be opened.
231      * @param monitor {@link ITaskMonitor} which is related to this URL
232      *            fetching.
233      * @return Returns an {@link InputStream} holding the URL content.
234      * @throws IOException Exception thrown when there are problems retrieving
235      *             the URL or its content.
236      * @throws CanceledByUserException Exception thrown if the user cancels the
237      *              authentication dialog.
238      */
openCachedUrl(String urlString, ITaskMonitor monitor)239     public InputStream openCachedUrl(String urlString, ITaskMonitor monitor)
240             throws IOException, CanceledByUserException {
241         // Don't cache in direct mode. Don't try to cache non-http URLs.
242         if (mStrategy == Strategy.DIRECT || !urlString.startsWith("http")) {        //$NON-NLS-1$
243             return openDirectUrl(urlString, monitor);
244         }
245 
246         File cached = new File(mCacheRoot, getCacheFilename(urlString));
247         File info   = new File(mCacheRoot, getInfoFilename(cached.getName()));
248 
249         boolean useCached = cached.exists();
250 
251         if (useCached && mStrategy == Strategy.FRESH_CACHE) {
252             // Check whether the file should be served from the cache or
253             // refreshed first.
254 
255             long cacheModifiedMs = cached.lastModified(); /* last mod time in epoch/millis */
256             boolean checkCache = true;
257 
258             Properties props = readInfo(info);
259             if (props == null) {
260                 // No properties, no chocolate for you.
261                 useCached = false;
262             } else {
263                 long minExpiration = System.currentTimeMillis() - MIN_TIME_EXPIRED_MS;
264                 checkCache = cacheModifiedMs < minExpiration;
265 
266                 if (!checkCache && DEBUG) {
267                     System.out.println(String.format(
268                             "%s : Too fresh [%,d ms], not checking yet.",    //$NON-NLS-1$
269                             urlString, cacheModifiedMs - minExpiration));
270                 }
271             }
272 
273             if (useCached && checkCache) {
274                 assert props != null;
275 
276                 // Right now we only support 200 codes and will requery all 404s.
277                 String code = props.getProperty(KEY_STATUS_CODE, "");   //$NON-NLS-1$
278                 useCached = Integer.toString(HttpStatus.SC_OK).equals(code);
279 
280                 if (!useCached && DEBUG) {
281                     System.out.println(String.format(
282                             "%s : cache disabled by code %s",           //$NON-NLS-1$
283                             urlString, code));
284                 }
285 
286                 if (useCached) {
287                     // Do we have a valid Content-Length? If so, it should match the file size.
288                     try {
289                         long length = Long.parseLong(props.getProperty(HttpHeaders.CONTENT_LENGTH,
290                                                         "-1")); //$NON-NLS-1$
291                         if (length >= 0) {
292                             useCached = length == cached.length();
293 
294                             if (!useCached && DEBUG) {
295                                 System.out.println(String.format(
296                                     "%s : cache disabled by length mismatch %d, expected %d", //$NON-NLS-1$
297                                     urlString, length, cached.length()));
298                             }
299                         }
300                     } catch (NumberFormatException ignore) {}
301                 }
302 
303                 if (useCached) {
304                     // Do we have an ETag and/or a Last-Modified?
305                     String etag = props.getProperty(HttpHeaders.ETAG);
306                     String lastMod = props.getProperty(HttpHeaders.LAST_MODIFIED);
307 
308                     if (etag != null || lastMod != null) {
309                         // Details on how to use them is defined at
310                         // http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.3.4
311                         // Bottom line:
312                         // - if there's an ETag, it should be used first with an
313                         //   If-None-Match header. That's a strong comparison for HTTP/1.1 servers.
314                         // - otherwise use a Last-Modified if an If-Modified-Since header exists.
315                         // In this case, we place both and the rules indicates a spec-abiding
316                         // server should strongly match ETag and weakly the Modified-Since.
317 
318                         // TODO there are some servers out there which report ETag/Last-Mod
319                         // yet don't honor them when presented with a precondition. In this
320                         // case we should identify it in the reply and invalidate ETag support
321                         // for these servers and instead fallback on the pure-timeout case below.
322 
323                         AtomicInteger statusCode = new AtomicInteger(0);
324                         InputStream is = null;
325                         List<Header> headers = new ArrayList<Header>(2);
326 
327                         if (etag != null) {
328                             headers.add(new BasicHeader(HttpHeaders.IF_NONE_MATCH, etag));
329                         }
330 
331                         if (lastMod != null) {
332                             headers.add(new BasicHeader(HttpHeaders.IF_MODIFIED_SINCE, lastMod));
333                         }
334 
335                         if (!headers.isEmpty()) {
336                             is = downloadAndCache(urlString, monitor, cached, info,
337                                     headers.toArray(new Header[headers.size()]),
338                                     statusCode);
339                         }
340 
341                         if (is != null && statusCode.get() == HttpStatus.SC_OK) {
342                             // The resource was modified, the server said there was something
343                             // new, which has been cached. We can return that to the caller.
344                             return is;
345                         }
346 
347                         // If we get here, we should have is == null and code
348                         // could be:
349                         // - 304 for not-modified -- same resource, still available, in
350                         //       which case we'll use the cached one.
351                         // - 404 -- resource doesn't exist anymore in which case there's
352                         //       no point in retrying.
353                         // - For any other code, just retry a download.
354 
355                         if (is != null) {
356                             try {
357                                 is.close();
358                             } catch (Exception ignore) {}
359                             is = null;
360                         }
361 
362                         if (statusCode.get() == HttpStatus.SC_NOT_MODIFIED) {
363                             // Cached file was not modified.
364                             // Change its timestamp for the next MIN_TIME_EXPIRED_MS check.
365                             cached.setLastModified(System.currentTimeMillis());
366 
367                             // At this point useCached==true so we'll return
368                             // the cached file below.
369                         } else {
370                             // URL fetch returned something other than 200 or 304.
371                             // For 404, we're done, no need to check the server again.
372                             // For all other codes, we'll retry a download below.
373                             useCached = false;
374                             if (statusCode.get() == HttpStatus.SC_NOT_FOUND) {
375                                 return null;
376                             }
377                         }
378                     } else {
379                         // If we don't have an Etag nor Last-Modified, let's use a
380                         // basic file timestamp and compare to a 1 hour threshold.
381 
382                         long maxExpiration = System.currentTimeMillis() - MAX_TIME_EXPIRED_MS;
383                         useCached = cacheModifiedMs >= maxExpiration;
384 
385                         if (!useCached && DEBUG) {
386                             System.out.println(String.format(
387                                 "[%1$s] cache disabled by timestamp %2$tD %2$tT < %3$tD %3$tT", //$NON-NLS-1$
388                                 urlString, cacheModifiedMs, maxExpiration));
389                         }
390                     }
391                 }
392             }
393         }
394 
395         if (useCached) {
396             // The caller needs an InputStream that supports the reset() operation.
397             // The default FileInputStream does not, so load the file into a byte
398             // array and return that.
399             try {
400                 InputStream is = readCachedFile(cached);
401                 if (is != null) {
402                     if (DEBUG) {
403                         System.out.println(String.format("%s : Use cached file", urlString)); //$NON-NLS-1$
404                     }
405 
406                     return is;
407                 }
408             } catch (IOException ignore) {}
409         }
410 
411         // If we're not using the cache, try to remove the cache and download again.
412         try {
413             cached.delete();
414             info.delete();
415         } catch (SecurityException ignore) {}
416 
417         return downloadAndCache(urlString, monitor, cached, info,
418                 null /*headers*/, null /*statusCode*/);
419     }
420 
421     // --------------
422 
readCachedFile(File cached)423     private InputStream readCachedFile(File cached) throws IOException {
424         InputStream is = null;
425 
426         int inc = 65536;
427         int curr = 0;
428         long len = cached.length();
429         assert len < Integer.MAX_VALUE;
430         if (len >= MAX_SMALL_FILE_SIZE) {
431             // This is supposed to cache small files, not 2+ GB files.
432             return null;
433         }
434         byte[] result = new byte[(int) (len > 0 ? len : inc)];
435 
436         try {
437             is = new FileInputStream(cached);
438 
439             int n;
440             while ((n = is.read(result, curr, result.length - curr)) != -1) {
441                 curr += n;
442                 if (curr == result.length) {
443                     byte[] temp = new byte[curr + inc];
444                     System.arraycopy(result, 0, temp, 0, curr);
445                     result = temp;
446                 }
447             }
448 
449             return new ByteArrayInputStream(result, 0, curr);
450 
451         } finally {
452             if (is != null) {
453                 try {
454                     is.close();
455                 } catch (IOException ignore) {}
456             }
457         }
458     }
459 
460     /**
461      * Download, cache and return as an in-memory byte stream.
462      * The download is only done if the server returns 200/OK.
463      * On success, store an info file next to the download with
464      * a few headers.
465      * <p/>
466      * This method deletes the cached file and the info file ONLY if it
467      * attempted a download and it failed to complete. It doesn't erase
468      * anything if there's no download because the server returned a 404
469      * or 304 or similar.
470      *
471      * @return An in-memory byte buffer input stream for the downloaded
472      *   and locally cached file, or null if nothing was downloaded
473      *   (including if it was a 304 Not-Modified status code.)
474      */
downloadAndCache( String urlString, ITaskMonitor monitor, File cached, File info, @Nullable Header[] headers, @Nullable AtomicInteger outStatusCode)475     private InputStream downloadAndCache(
476             String urlString,
477             ITaskMonitor monitor,
478             File cached,
479             File info,
480             @Nullable Header[] headers,
481             @Nullable AtomicInteger outStatusCode)
482                 throws FileNotFoundException, IOException, CanceledByUserException {
483         InputStream is = null;
484         OutputStream os = null;
485 
486         int inc = 65536;
487         int curr = 0;
488         byte[] result = new byte[inc];
489 
490         try {
491             Pair<InputStream, HttpResponse> r = UrlOpener.openUrl(urlString, monitor, headers);
492 
493             is = r.getFirst();
494             HttpResponse response = r.getSecond();
495 
496             if (DEBUG) {
497                 System.out.println(String.format("%s : fetch: %s => %s",  //$NON-NLS-1$
498                         urlString,
499                         headers == null ? "" : Arrays.toString(headers),    //$NON-NLS-1$
500                         response.getStatusLine()));
501             }
502 
503             int code = response.getStatusLine().getStatusCode();
504 
505             if (outStatusCode != null) {
506                 outStatusCode.set(code);
507             }
508 
509             if (code != HttpStatus.SC_OK) {
510                 // Only a 200 response code makes sense here.
511                 // Even the other 20x codes should not apply, e.g. no content or partial
512                 // content are not statuses we want to handle and should never happen.
513                 // (see http://www.w3.org/Protocols/rfc2616/rfc2616-sec6.html#sec6.1.1 for list)
514                 return null;
515             }
516 
517             os = new FileOutputStream(cached);
518 
519             int n;
520             while ((n = is.read(result, curr, result.length - curr)) != -1) {
521                 if (os != null && n > 0) {
522                     os.write(result, curr, n);
523                 }
524 
525                 curr += n;
526 
527                 if (os != null && curr > MAX_SMALL_FILE_SIZE) {
528                     // If the file size exceeds our "small file size" threshold,
529                     // stop caching. We don't want to fill the disk.
530                     try {
531                         os.close();
532                     } catch (IOException ignore) {}
533                     try {
534                         cached.delete();
535                         info.delete();
536                     } catch (SecurityException ignore) {}
537                     os = null;
538                 }
539                 if (curr == result.length) {
540                     byte[] temp = new byte[curr + inc];
541                     System.arraycopy(result, 0, temp, 0, curr);
542                     result = temp;
543                 }
544             }
545 
546             // Close the output stream, signaling it was stored properly.
547             if (os != null) {
548                 try {
549                     os.close();
550                     os = null;
551 
552                     saveInfo(urlString, response, info);
553                 } catch (IOException ignore) {}
554             }
555 
556             return new ByteArrayInputStream(result, 0, curr);
557 
558         } finally {
559             if (is != null) {
560                 try {
561                     is.close();
562                 } catch (IOException ignore) {}
563             }
564             if (os != null) {
565                 try {
566                     os.close();
567                 } catch (IOException ignore) {}
568                 // If we get here with the output stream not null, it means there
569                 // was an issue and we don't want to keep that file. We'll try to
570                 // delete it.
571                 try {
572                     cached.delete();
573                     info.delete();
574                 } catch (SecurityException ignore) {}
575             }
576         }
577     }
578 
579     /**
580      * Saves part of the HTTP Response to the info file.
581      */
saveInfo(String urlString, HttpResponse response, File info)582     private void saveInfo(String urlString, HttpResponse response, File info) throws IOException {
583         Properties props = new Properties();
584 
585         // we don't need the status code & URL right now.
586         // Save it in case we want to have it later (e.g. to differentiate 200 and 404.)
587         props.setProperty(KEY_URL, urlString);
588         props.setProperty(KEY_STATUS_CODE,
589                 Integer.toString(response.getStatusLine().getStatusCode()));
590 
591         for (String name : INFO_HTTP_HEADERS) {
592             Header h = response.getFirstHeader(name);
593             if (h != null) {
594                 props.setProperty(name, h.getValue());
595             }
596         }
597 
598         FileOutputStream os = null;
599         try {
600             os = new FileOutputStream(info);
601             props.store(os, "## Meta data for SDK Manager cache. Do not modify."); //$NON-NLS-1$
602         } finally {
603             if (os != null) {
604                 os.close();
605             }
606         }
607     }
608 
609     /**
610      * Reads the info properties file.
611      * @return The properties found or null if there's no file or it can't be read.
612      */
readInfo(File info)613     private Properties readInfo(File info) {
614         if (info.exists()) {
615             Properties props = new Properties();
616 
617             InputStream is = null;
618             try {
619                 is = new FileInputStream(info);
620                 props.load(is);
621                 return props;
622             } catch (IOException ignore) {
623             } finally {
624                 if (is != null) {
625                     try {
626                         is.close();
627                     } catch (IOException ignore) {}
628                 }
629             }
630         }
631         return null;
632     }
633 
634     /**
635      * Computes the cache filename for the given URL.
636      * The filename uses the {@link #BIN_FILE_PREFIX}, the full URL string's hashcode and
637      * a sanitized portion of the URL filename. The returned filename is never
638      * more than 64 characters to ensure maximum file system compatibility.
639      *
640      * @param urlString The download URL.
641      * @return A leaf filename for the cached download file.
642      */
getCacheFilename(String urlString)643     private String getCacheFilename(String urlString) {
644         String hash = String.format("%08x", urlString.hashCode());
645 
646         String leaf = urlString.toLowerCase(Locale.US);
647         if (leaf.length() >= 2) {
648             int index = urlString.lastIndexOf('/', leaf.length() - 2);
649             leaf = urlString.substring(index + 1);
650         }
651 
652         leaf = leaf.replaceAll("[^a-z0-9_-]+", "_");
653         leaf = leaf.replaceAll("__+", "_");
654 
655         leaf = hash + '-' + leaf;
656         int n = 64 - BIN_FILE_PREFIX.length();
657         if (leaf.length() > n) {
658             leaf = leaf.substring(0, n);
659         }
660 
661         return BIN_FILE_PREFIX + leaf;
662     }
663 
getInfoFilename(String cacheFilename)664     private String getInfoFilename(String cacheFilename) {
665         return cacheFilename.replaceFirst(BIN_FILE_PREFIX, INFO_FILE_PREFIX);
666     }
667 }
668