| /* |
| * Copyright (C) 2015 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.tools.idea.sdk.remote.internal; |
| |
| import com.android.SdkConstants; |
| import com.android.annotations.NonNull; |
| import com.android.annotations.Nullable; |
| import com.android.annotations.VisibleForTesting; |
| import com.android.annotations.VisibleForTesting.Visibility; |
| import com.android.prefs.AndroidLocation; |
| import com.android.prefs.AndroidLocation.AndroidLocationException; |
| import com.android.sdklib.io.FileOp; |
| import com.android.sdklib.io.IFileOp; |
| import com.android.tools.idea.sdk.remote.internal.sources.SdkAddonsListConstants; |
| import com.android.utils.Pair; |
| import com.intellij.util.net.HttpConfigurable; |
| import org.apache.http.*; |
| import org.apache.http.message.BasicHeader; |
| import org.apache.http.message.BasicHttpResponse; |
| import org.apache.http.message.BasicStatusLine; |
| |
| import java.io.*; |
| import java.net.HttpURLConnection; |
| import java.util.*; |
| import java.util.concurrent.atomic.AtomicInteger; |
| |
| |
| /** |
| * A simple cache for the XML resources handled by the SDK Manager. |
| * <p/> |
| * Callers should use {@link #openDirectUrl} to download "large files" |
| * that should not be cached (like actual installation packages which are several MBs big) |
| * and call {@link #openCachedUrl(String, ITaskMonitor)} to download small XML files. |
| * <p/> |
| * The cache can work in 3 different strategies (direct is a pass-through, fresh-cache is the |
| * default and tries to update resources if they are older than 10 minutes by respecting |
| * either ETag or Last-Modified, and finally server-cache is a strategy to always serve |
| * cached entries if present.) |
| */ |
| public class DownloadCache { |
| |
| /* |
| * HTTP/1.1 references: |
| * - Possible headers: |
| * http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html |
| * - Rules about conditional requests: |
| * http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.3.4 |
| * - Error codes: |
| * http://www.w3.org/Protocols/rfc2616/rfc2616-sec6.html#sec6.1.1 |
| */ |
| |
| private static final boolean DEBUG = System.getenv("SDKMAN_DEBUG_CACHE") != null; //$NON-NLS-1$ |
| |
| /** Key for the Status-Code in the info properties. */ |
| private static final String KEY_STATUS_CODE = "Status-Code"; //$NON-NLS-1$ |
| /** Key for the URL in the info properties. */ |
| private static final String KEY_URL = "URL"; //$NON-NLS-1$ |
| |
| /** Prefix of binary files stored in the {@link SdkConstants#FD_CACHE} directory. */ |
| private static final String BIN_FILE_PREFIX = "sdkbin"; //$NON-NLS-1$ |
| /** Prefix of meta info files stored in the {@link SdkConstants#FD_CACHE} directory. */ |
| private static final String INFO_FILE_PREFIX = "sdkinf"; //$NON-NLS-1$ |
| /* Revision suffixed to the prefix. */ |
| private static final String REV_FILE_PREFIX = "-1_"; //$NON-NLS-1$ |
| |
| /** |
| * Minimum time before we consider a cached entry is potentially stale. |
| * Expressed in milliseconds. |
| * <p/> |
| * When using the {@link Strategy#FRESH_CACHE}, the cache will not try to refresh |
| * a cached file if it's has been saved more recently than this time. |
| * When using the direct mode or the serve mode, the cache either doesn't serve |
| * cached files or always serves caches files so this expiration delay is not used. |
| * <p/> |
| * Default is 10 minutes. |
| * <p/> |
| * TODO: change for a dynamic preference later. |
| */ |
| private static final long MIN_TIME_EXPIRED_MS = 10*60*1000; |
| /** |
| * Maximum time before we consider a cache entry to be stale. |
| * Expressed in milliseconds. |
| * <p/> |
| * When using the {@link Strategy#FRESH_CACHE}, entries that have no ETag |
| * or Last-Modified will be refreshed if their file timestamp is older than |
| * this value. |
| * <p/> |
| * Default is 4 hours. |
| * <p/> |
| * TODO: change for a dynamic preference later. |
| */ |
| private static final long MAX_TIME_EXPIRED_MS = 4*60*60*1000; |
| |
| /** |
| * The maximum file size we'll cache for "small" files. |
| * 640KB is more than enough and is already a stretch since these are read in memory. |
| * (The actual typical size of the files handled here is in the 4-64KB range.) |
| */ |
| private static final int MAX_SMALL_FILE_SIZE = 640 * 1024; |
| |
| /** |
| * HTTP Headers that are saved in an info file. |
| * For HTTP/1.1 header names, see http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html |
| */ |
| private static final String[] INFO_HTTP_HEADERS = { |
| HttpHeaders.LAST_MODIFIED, |
| HttpHeaders.ETAG, |
| HttpHeaders.CONTENT_LENGTH, |
| HttpHeaders.DATE |
| }; |
| |
| private final IFileOp mFileOp; |
| private final File mCacheRoot; |
| private final Strategy mStrategy; |
| |
| public enum Strategy { |
| /** |
| * Exclusively serves data from the cache. If files are available in the |
| * cache, serve them as is (without trying to refresh them). If files are |
| * not available, they are <em>not</em> fetched at all. |
| */ |
| ONLY_CACHE, |
| /** |
| * If the files are available in the cache, serve them as-is, otherwise |
| * download them and return the cached version. No expiration or refresh |
| * is attempted if a file is in the cache. |
| */ |
| SERVE_CACHE, |
| /** |
| * If the files are available in the cache, check if there's an update |
| * (either using an e-tag check or comparing to the default time expiration). |
| * If files have expired or are not in the cache then download them and return |
| * the cached version. |
| */ |
| FRESH_CACHE, |
| /** |
| * Disables caching. URLs are always downloaded and returned directly. |
| * Downloaded streams aren't cached locally. |
| */ |
| DIRECT |
| } |
| |
| /** Creates a default instance of the URL cache */ |
| public DownloadCache(@NonNull Strategy strategy) { |
| this(new FileOp(), strategy); |
| } |
| |
| /** Creates a default instance of the URL cache */ |
| public DownloadCache(@NonNull IFileOp fileOp, @NonNull Strategy strategy) { |
| mFileOp = fileOp; |
| mCacheRoot = initCacheRoot(); |
| |
| // If this is defined in the environment, never use the cache. Useful for testing. |
| if (System.getenv("SDKMAN_DISABLE_CACHE") != null) { //$NON-NLS-1$ |
| strategy = Strategy.DIRECT; |
| } |
| |
| mStrategy = mCacheRoot == null ? Strategy.DIRECT : strategy; |
| } |
| |
| @NonNull |
| public Strategy getStrategy() { |
| return mStrategy; |
| } |
| |
| /** |
| * Removes all cached files from the cache directory. |
| */ |
| public void clearCache() { |
| if (mCacheRoot != null) { |
| File[] files = mFileOp.listFiles(mCacheRoot); |
| for (File f : files) { |
| if (mFileOp.isFile(f)) { |
| String name = f.getName(); |
| if (name.startsWith(BIN_FILE_PREFIX) || name.startsWith(INFO_FILE_PREFIX)) { |
| mFileOp.delete(f); |
| } |
| } |
| } |
| } |
| } |
| |
| /** |
| * Returns the directory to be used as a cache. |
| * Creates it if necessary. |
| * Makes it possible to disable or override the cache location in unit tests. |
| * |
| * @return An existing directory to use as a cache root dir, |
| * or null in case of error in which case the cache will be disabled. |
| */ |
| @VisibleForTesting(visibility=Visibility.PRIVATE) |
| @Nullable |
| protected File initCacheRoot() { |
| try { |
| File root = new File(AndroidLocation.getFolder()); |
| root = new File(root, SdkConstants.FD_CACHE); |
| if (!mFileOp.exists(root)) { |
| mFileOp.mkdirs(root); |
| } |
| return root; |
| } catch (AndroidLocationException e) { |
| // No root? Disable the cache. |
| return null; |
| } |
| } |
| |
| /** |
| * Calls {@link HttpConfigurable#openHttpConnection(String)} |
| * to actually perform a download. |
| * <p/> |
| * Isolated so that it can be overridden by unit tests. |
| */ |
| @VisibleForTesting(visibility=Visibility.PRIVATE) |
| @NonNull |
| protected Pair<InputStream, HttpURLConnection> openUrl( |
| @NonNull String url, |
| boolean needsMarkResetSupport, |
| @NonNull ITaskMonitor monitor, |
| @Nullable Header[] headers) throws IOException, CanceledByUserException { |
| HttpURLConnection connection = HttpConfigurable.getInstance().openHttpConnection(url); |
| if (headers != null) { |
| for (Header header : headers) { |
| connection.setRequestProperty(header.getName(), header.getValue()); |
| } |
| } |
| connection.connect(); |
| InputStream is = connection.getInputStream(); |
| if (needsMarkResetSupport) { |
| is = ensureMarkReset(is); |
| } |
| |
| return Pair.of(is, connection); |
| } |
| |
| private InputStream ensureMarkReset(InputStream is) { |
| // If the caller requires an InputStream that supports mark/reset, let's |
| // make sure we have such a stream. |
| if (is != null) { |
| if (!is.markSupported()) { |
| try { |
| // Consume the whole input stream and offer a byte array stream instead. |
| // This can only work sanely if the resource is a small file that can |
| // fit in memory. It also means the caller has no chance of showing |
| // a meaningful download progress. |
| |
| InputStream is2 = toByteArrayInputStream(is); |
| if (is2 != null) { |
| try { |
| is.close(); |
| } |
| catch (Exception ignore) { |
| } |
| return is2; |
| } |
| } |
| catch (Exception e3) { |
| // Ignore. If this can't work, caller will fail later. |
| } |
| } |
| } |
| return is; |
| } |
| |
| // ByteArrayInputStream is the duct tape of input streams. |
| private static InputStream toByteArrayInputStream(InputStream is) throws IOException { |
| int inc = 4096; |
| int curr = 0; |
| byte[] result = new byte[inc]; |
| |
| int n; |
| while ((n = is.read(result, curr, result.length - curr)) != -1) { |
| curr += n; |
| if (curr == result.length) { |
| byte[] temp = new byte[curr + inc]; |
| System.arraycopy(result, 0, temp, 0, curr); |
| result = temp; |
| } |
| } |
| |
| return new ByteArrayInputStream(result, 0, curr); |
| } |
| |
| |
| /** |
| * Does a direct download of the given URL using {@link HttpConfigurable#openHttpConnection(String)}. |
| * This does not check the download cache and does not attempt to cache the file. |
| * Instead the HttpClient library returns a progressive download stream. |
| * <p/> |
| * For details on realm authentication and user/password handling, |
| * see {@link HttpConfigurable#openHttpConnection(String)}. |
| * <p/> |
| * The resulting input stream may not support mark/reset. |
| * |
| * @param urlString the URL string to be opened. |
| * @param headers An optional set of headers to pass when requesting the resource. Can be null. |
| * @param monitor {@link ITaskMonitor} which is related to this URL |
| * fetching. |
| * @return Returns a pair with a {@link InputStream} and an {@link HttpResponse}. |
| * The pair is never null. |
| * The input stream can be null in case of error, although in general the |
| * method will probably throw an exception instead. |
| * The caller should look at the response code's status and only accept the |
| * input stream if it's the desired code (e.g. 200 or 206). |
| * @throws IOException Exception thrown when there are problems retrieving |
| * the URL or its content. |
| * @throws CanceledByUserException Exception thrown if the user cancels the |
| * authentication dialog. |
| */ |
| @NonNull |
| public Pair<InputStream, HttpURLConnection> openDirectUrl( |
| @NonNull String urlString, |
| @Nullable Header[] headers, |
| @NonNull ITaskMonitor monitor) |
| throws IOException, CanceledByUserException { |
| if (DEBUG) { |
| System.out.println(String.format("%s : Direct download", urlString)); //$NON-NLS-1$ |
| } |
| return openUrl( |
| urlString, |
| false /*needsMarkResetSupport*/, |
| monitor, |
| headers); |
| } |
| |
| /** |
| * This is a simplified convenience method that calls |
| * {@link #openDirectUrl(String, Header[], ITaskMonitor)} |
| * without passing any specific HTTP headers and returns the resulting input stream |
| * and the HTTP status code. |
| * See the original method's description for details on its behavior. |
| * <p/> |
| * {@link #openDirectUrl(String, Header[], ITaskMonitor)} can accept customized |
| * HTTP headers to send with the requests and also returns the full HTTP |
| * response -- status line with code and protocol and all headers. |
| * <p/> |
| * The resulting input stream may not support mark/reset. |
| * |
| * @param urlString the URL string to be opened. |
| * @param monitor {@link ITaskMonitor} which is related to this URL |
| * fetching. |
| * @return Returns a pair with a {@link InputStream} and an HTTP status code. |
| * The pair is never null. |
| * The input stream can be null in case of error, although in general the |
| * method will probably throw an exception instead. |
| * The caller should look at the response code's status and only accept the |
| * input stream if it's the desired code (e.g. 200 or 206). |
| * @throws IOException Exception thrown when there are problems retrieving |
| * the URL or its content. |
| * @throws CanceledByUserException Exception thrown if the user cancels the |
| * authentication dialog. |
| * @see #openDirectUrl(String, Header[], ITaskMonitor) |
| */ |
| @NonNull |
| public Pair<InputStream, Integer> openDirectUrl( |
| @NonNull String urlString, |
| @NonNull ITaskMonitor monitor) |
| throws IOException, CanceledByUserException { |
| if (DEBUG) { |
| System.out.println(String.format("%s : Direct download", urlString)); //$NON-NLS-1$ |
| } |
| Pair<InputStream, HttpURLConnection> result = openUrl( |
| urlString, |
| false /*needsMarkResetSupport*/, |
| monitor, |
| null /*headers*/); |
| return Pair.of(result.getFirst(), result.getSecond().getResponseCode()); |
| } |
| |
| /** |
| * Downloads a small file, typically XML manifests. |
| * The current {@link Strategy} governs whether the file is served as-is |
| * from the cache, potentially updated first or directly downloaded. |
| * <p/> |
| * For large downloads (e.g. installable archives) please do not invoke the |
| * cache and instead use the {@link #openDirectUrl} method. |
| * <p/> |
| * For details on realm authentication and user/password handling, |
| * see {@link HttpConfigurable#openHttpConnection(String)}. |
| * |
| * @param urlString the URL string to be opened. |
| * @param monitor {@link ITaskMonitor} which is related to this URL |
| * fetching. |
| * @return Returns an {@link InputStream} holding the URL content. |
| * Returns null if there's no content (e.g. resource not found.) |
| * Returns null if the document is not cached and strategy is {@link Strategy#ONLY_CACHE}. |
| * @throws IOException Exception thrown when there are problems retrieving |
| * the URL or its content. |
| * @throws CanceledByUserException Exception thrown if the user cancels the |
| * authentication dialog. |
| */ |
| @NonNull |
| public InputStream openCachedUrl(@NonNull String urlString, @NonNull ITaskMonitor monitor) |
| throws IOException, CanceledByUserException { |
| // Don't cache in direct mode. |
| if (mStrategy == Strategy.DIRECT) { |
| Pair<InputStream, HttpURLConnection> result = openUrl( |
| urlString, |
| true /*needsMarkResetSupport*/, |
| monitor, |
| null /*headers*/); |
| return result.getFirst(); |
| } |
| |
| File cached = new File(mCacheRoot, getCacheFilename(urlString)); |
| File info = new File(mCacheRoot, getInfoFilename(cached.getName())); |
| |
| boolean useCached = mFileOp.exists(cached); |
| |
| if (useCached && mStrategy == Strategy.FRESH_CACHE) { |
| // Check whether the file should be served from the cache or |
| // refreshed first. |
| |
| long cacheModifiedMs = mFileOp.lastModified(cached); /* last mod time in epoch/millis */ |
| boolean checkCache = true; |
| |
| Properties props = readInfo(info); |
| if (props == null) { |
| // No properties, no chocolate for you. |
| useCached = false; |
| } else { |
| long minExpiration = System.currentTimeMillis() - MIN_TIME_EXPIRED_MS; |
| checkCache = cacheModifiedMs < minExpiration; |
| |
| if (!checkCache && DEBUG) { |
| System.out.println(String.format( |
| "%s : Too fresh [%,d ms], not checking yet.", //$NON-NLS-1$ |
| urlString, cacheModifiedMs - minExpiration)); |
| } |
| } |
| |
| if (useCached && checkCache) { |
| assert props != null; |
| |
| // Right now we only support 200 codes and will requery all 404s. |
| String code = props.getProperty(KEY_STATUS_CODE, ""); //$NON-NLS-1$ |
| useCached = Integer.toString(HttpStatus.SC_OK).equals(code); |
| |
| if (!useCached && DEBUG) { |
| System.out.println(String.format( |
| "%s : cache disabled by code %s", //$NON-NLS-1$ |
| urlString, code)); |
| } |
| |
| if (useCached) { |
| // Do we have a valid Content-Length? If so, it should match the file size. |
| try { |
| long length = Long.parseLong(props.getProperty(HttpHeaders.CONTENT_LENGTH, |
| "-1")); //$NON-NLS-1$ |
| if (length >= 0) { |
| useCached = length == mFileOp.length(cached); |
| |
| if (!useCached && DEBUG) { |
| System.out.println(String.format( |
| "%s : cache disabled by length mismatch %d, expected %d", //$NON-NLS-1$ |
| urlString, length, cached.length())); |
| } |
| } |
| } catch (NumberFormatException ignore) {} |
| } |
| |
| if (useCached) { |
| // Do we have an ETag and/or a Last-Modified? |
| String etag = props.getProperty(HttpHeaders.ETAG); |
| String lastMod = props.getProperty(HttpHeaders.LAST_MODIFIED); |
| |
| if (etag != null || lastMod != null) { |
| // Details on how to use them is defined at |
| // http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.3.4 |
| // Bottom line: |
| // - if there's an ETag, it should be used first with an |
| // If-None-Match header. That's a strong comparison for HTTP/1.1 servers. |
| // - otherwise use a Last-Modified if an If-Modified-Since header exists. |
| // In this case, we place both and the rules indicates a spec-abiding |
| // server should strongly match ETag and weakly the Modified-Since. |
| |
| // TODO there are some servers out there which report ETag/Last-Mod |
| // yet don't honor them when presented with a precondition. In this |
| // case we should identify it in the reply and invalidate ETag support |
| // for these servers and instead fallback on the pure-timeout case below. |
| |
| AtomicInteger statusCode = new AtomicInteger(0); |
| InputStream is = null; |
| List<Header> headers = new ArrayList<Header>(2); |
| |
| if (etag != null) { |
| headers.add(new BasicHeader(HttpHeaders.IF_NONE_MATCH, etag)); |
| } |
| |
| if (lastMod != null) { |
| headers.add(new BasicHeader(HttpHeaders.IF_MODIFIED_SINCE, lastMod)); |
| } |
| |
| if (!headers.isEmpty()) { |
| is = downloadAndCache(urlString, monitor, cached, info, |
| headers.toArray(new Header[headers.size()]), |
| statusCode); |
| } |
| |
| if (is != null && statusCode.get() == HttpStatus.SC_OK) { |
| // The resource was modified, the server said there was something |
| // new, which has been cached. We can return that to the caller. |
| return is; |
| } |
| |
| // If we get here, we should have is == null and code |
| // could be: |
| // - 304 for not-modified -- same resource, still available, in |
| // which case we'll use the cached one. |
| // - 404 -- resource doesn't exist anymore in which case there's |
| // no point in retrying. |
| // - For any other code, just retry a download. |
| |
| if (is != null) { |
| try { |
| is.close(); |
| } catch (Exception ignore) {} |
| is = null; |
| } |
| |
| if (statusCode.get() == HttpStatus.SC_NOT_MODIFIED) { |
| // Cached file was not modified. |
| // Change its timestamp for the next MIN_TIME_EXPIRED_MS check. |
| cached.setLastModified(System.currentTimeMillis()); |
| |
| // At this point useCached==true so we'll return |
| // the cached file below. |
| } else { |
| // URL fetch returned something other than 200 or 304. |
| // For 404, we're done, no need to check the server again. |
| // For all other codes, we'll retry a download below. |
| useCached = false; |
| if (statusCode.get() == HttpStatus.SC_NOT_FOUND) { |
| return null; |
| } |
| } |
| } else { |
| // If we don't have an Etag nor Last-Modified, let's use a |
| // basic file timestamp and compare to a 1 hour threshold. |
| |
| long maxExpiration = System.currentTimeMillis() - MAX_TIME_EXPIRED_MS; |
| useCached = cacheModifiedMs >= maxExpiration; |
| |
| if (!useCached && DEBUG) { |
| System.out.println(String.format( |
| "[%1$s] cache disabled by timestamp %2$tD %2$tT < %3$tD %3$tT", //$NON-NLS-1$ |
| urlString, cacheModifiedMs, maxExpiration)); |
| } |
| } |
| } |
| } |
| } |
| |
| if (useCached) { |
| // The caller needs an InputStream that supports the reset() operation. |
| // The default FileInputStream does not, so load the file into a byte |
| // array and return that. |
| try { |
| InputStream is = readCachedFile(cached); |
| if (is != null) { |
| if (DEBUG) { |
| System.out.println(String.format("%s : Use cached file", urlString)); //$NON-NLS-1$ |
| } |
| |
| return is; |
| } |
| } catch (IOException ignore) {} |
| } |
| |
| if (!useCached && mStrategy == Strategy.ONLY_CACHE) { |
| // We don't have a document to serve from the cache. |
| if (DEBUG) { |
| System.out.println(String.format("%s : file not in cache", urlString)); //$NON-NLS-1$ |
| } |
| return null; |
| } |
| |
| // If we're not using the cache, try to remove the cache and download again. |
| try { |
| mFileOp.delete(cached); |
| mFileOp.delete(info); |
| } catch (SecurityException ignore) {} |
| |
| return downloadAndCache(urlString, monitor, cached, info, |
| null /*headers*/, null /*statusCode*/); |
| } |
| |
| |
| |
| // -------------- |
| |
| @Nullable |
| private InputStream readCachedFile(@NonNull File cached) throws IOException { |
| InputStream is = null; |
| |
| int inc = 65536; |
| int curr = 0; |
| long len = cached.length(); |
| assert len < Integer.MAX_VALUE; |
| if (len >= MAX_SMALL_FILE_SIZE) { |
| // This is supposed to cache small files, not 2+ GB files. |
| return null; |
| } |
| byte[] result = new byte[(int) (len > 0 ? len : inc)]; |
| |
| try { |
| is = mFileOp.newFileInputStream(cached); |
| |
| int n; |
| while ((n = is.read(result, curr, result.length - curr)) != -1) { |
| curr += n; |
| if (curr == result.length) { |
| byte[] temp = new byte[curr + inc]; |
| System.arraycopy(result, 0, temp, 0, curr); |
| result = temp; |
| } |
| } |
| |
| return new ByteArrayInputStream(result, 0, curr); |
| |
| } finally { |
| if (is != null) { |
| try { |
| is.close(); |
| } catch (IOException ignore) {} |
| } |
| } |
| } |
| |
| /** |
| * Download, cache and return as an in-memory byte stream. |
| * The download is only done if the server returns 200/OK. |
| * On success, store an info file next to the download with |
| * a few headers. |
| * <p/> |
| * This method deletes the cached file and the info file ONLY if it |
| * attempted a download and it failed to complete. It doesn't erase |
| * anything if there's no download because the server returned a 404 |
| * or 304 or similar. |
| * |
| * @return An in-memory byte buffer input stream for the downloaded |
| * and locally cached file, or null if nothing was downloaded |
| * (including if it was a 304 Not-Modified status code.) |
| */ |
| @Nullable |
| private InputStream downloadAndCache( |
| @NonNull String urlString, |
| @NonNull ITaskMonitor monitor, |
| @NonNull File cached, |
| @NonNull File info, |
| @Nullable Header[] headers, |
| @Nullable AtomicInteger outStatusCode) |
| throws FileNotFoundException, IOException, CanceledByUserException { |
| InputStream is = null; |
| OutputStream os = null; |
| |
| int inc = 65536; |
| int curr = 0; |
| byte[] result = new byte[inc]; |
| |
| try { |
| Pair<InputStream, HttpURLConnection> r = |
| openUrl(urlString, true /*needsMarkResetSupport*/, monitor, headers); |
| |
| is = r.getFirst(); |
| HttpURLConnection connection = r.getSecond(); |
| |
| if (DEBUG) { |
| System.out.println(String.format("%s : fetch: %s => %s", //$NON-NLS-1$ |
| urlString, headers == null ? "" : Arrays.toString(headers), //$NON-NLS-1$ |
| connection.getResponseMessage())); |
| } |
| |
| int code = connection.getResponseCode(); |
| |
| if (outStatusCode != null) { |
| outStatusCode.set(code); |
| } |
| |
| if (code != HttpStatus.SC_OK) { |
| // Only a 200 response code makes sense here. |
| // Even the other 20x codes should not apply, e.g. no content or partial |
| // content are not statuses we want to handle and should never happen. |
| // (see http://www.w3.org/Protocols/rfc2616/rfc2616-sec6.html#sec6.1.1 for list) |
| return null; |
| } |
| |
| os = mFileOp.newFileOutputStream(cached); |
| |
| int n; |
| while ((n = is.read(result, curr, result.length - curr)) != -1) { |
| if (os != null && n > 0) { |
| os.write(result, curr, n); |
| } |
| |
| curr += n; |
| |
| if (os != null && curr > MAX_SMALL_FILE_SIZE) { |
| // If the file size exceeds our "small file size" threshold, |
| // stop caching. We don't want to fill the disk. |
| try { |
| os.close(); |
| } catch (IOException ignore) {} |
| try { |
| cached.delete(); |
| info.delete(); |
| } catch (SecurityException ignore) {} |
| os = null; |
| } |
| if (curr == result.length) { |
| byte[] temp = new byte[curr + inc]; |
| System.arraycopy(result, 0, temp, 0, curr); |
| result = temp; |
| } |
| } |
| |
| // Close the output stream, signaling it was stored properly. |
| if (os != null) { |
| try { |
| os.close(); |
| os = null; |
| |
| saveInfo(urlString, connection, info); |
| } catch (IOException ignore) {} |
| } |
| |
| return new ByteArrayInputStream(result, 0, curr); |
| |
| } finally { |
| if (is != null) { |
| try { |
| is.close(); |
| } catch (IOException ignore) {} |
| } |
| if (os != null) { |
| try { |
| os.close(); |
| } catch (IOException ignore) {} |
| // If we get here with the output stream not null, it means there |
| // was an issue and we don't want to keep that file. We'll try to |
| // delete it. |
| try { |
| mFileOp.delete(cached); |
| mFileOp.delete(info); |
| } catch (SecurityException ignore) {} |
| } |
| } |
| } |
| |
| /** |
| * Saves part of the HTTP Response to the info file. |
| */ |
| private void saveInfo( |
| @NonNull String urlString, |
| @NonNull HttpURLConnection connection, |
| @NonNull File info) throws IOException { |
| Properties props = new Properties(); |
| |
| // we don't need the status code & URL right now. |
| // Save it in case we want to have it later (e.g. to differentiate 200 and 404.) |
| props.setProperty(KEY_URL, urlString); |
| props.setProperty(KEY_STATUS_CODE, |
| Integer.toString(connection.getResponseCode())); |
| |
| for (String name : INFO_HTTP_HEADERS) { |
| String h = connection.getHeaderField(name); |
| if (h != null) { |
| props.setProperty(name, h); |
| } |
| } |
| |
| mFileOp.saveProperties(info, props, "## Meta data for SDK Manager cache. Do not modify."); //$NON-NLS-1$ |
| } |
| |
| /** |
| * Reads the info properties file. |
| * @return The properties found or null if there's no file or it can't be read. |
| */ |
| @Nullable |
| private Properties readInfo(@NonNull File info) { |
| if (mFileOp.exists(info)) { |
| return mFileOp.loadProperties(info); |
| } |
| return null; |
| } |
| |
| /** |
| * Computes the cache filename for the given URL. |
| * The filename uses the {@link #BIN_FILE_PREFIX}, the full URL string's hashcode and |
| * a sanitized portion of the URL filename. The returned filename is never |
| * more than 64 characters to ensure maximum file system compatibility. |
| * |
| * @param urlString The download URL. |
| * @return A leaf filename for the cached download file. |
| */ |
| @NonNull |
| private String getCacheFilename(@NonNull String urlString) { |
| |
| int code = 0; |
| for (int i = 0, j = urlString.length(); i < j; i++) { |
| code = code * 31 + urlString.charAt(i); |
| } |
| String hash = String.format("%08x", code); |
| |
| String leaf = urlString.toLowerCase(Locale.US); |
| if (leaf.length() >= 2) { |
| int index = urlString.lastIndexOf('/', leaf.length() - 2); |
| leaf = urlString.substring(index + 1); |
| } |
| |
| leaf = leaf.replaceAll("[^a-z0-9_-]+", "_"); |
| leaf = leaf.replaceAll("__+", "_"); |
| |
| leaf = hash + '-' + leaf; |
| String prefix = BIN_FILE_PREFIX + REV_FILE_PREFIX; |
| int n = 64 - prefix.length(); |
| if (leaf.length() > n) { |
| leaf = leaf.substring(0, n); |
| } |
| |
| return prefix + leaf; |
| } |
| |
| @NonNull |
| private String getInfoFilename(@NonNull String cacheFilename) { |
| return cacheFilename.replaceFirst(BIN_FILE_PREFIX, INFO_FILE_PREFIX); |
| } |
| } |