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