| /* |
| * Copyright 2014 Google Inc. All rights reserved. |
| * |
| * 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.google.samples.apps.iosched.sync; |
| |
| import android.content.Context; |
| import android.text.TextUtils; |
| |
| import com.google.samples.apps.iosched.Config; |
| import com.google.gson.Gson; |
| import com.google.samples.apps.iosched.R; |
| import com.google.samples.apps.iosched.io.model.DataManifest; |
| import com.google.samples.apps.iosched.util.FileUtils; |
| import com.google.samples.apps.iosched.util.HashUtils; |
| import com.google.samples.apps.iosched.util.TimeUtils; |
| |
| import java.io.File; |
| import java.io.IOException; |
| import java.net.HttpURLConnection; |
| import java.util.HashSet; |
| import java.util.List; |
| |
| import com.turbomanage.httpclient.BasicHttpClient; |
| import com.turbomanage.httpclient.ConsoleRequestLogger; |
| import com.turbomanage.httpclient.HttpResponse; |
| import com.turbomanage.httpclient.RequestLogger; |
| |
| import static com.google.samples.apps.iosched.util.LogUtils.*; |
| |
| /** |
| * Helper class that fetches conference data from the remote server. |
| */ |
| public class RemoteConferenceDataFetcher { |
| private static final String TAG = makeLogTag(SyncHelper.class); |
| |
| // The directory under which we cache our downloaded files |
| private static String CACHE_DIR = "data_cache"; |
| |
| private Context mContext = null; |
| |
| // name of URL override file used for debug purposes |
| private static final String URL_OVERRIDE_FILE_NAME = "iosched_manifest_url_override.txt"; |
| |
| // URL of the remote manifest file |
| private String mManifestUrl = null; |
| |
| // timestamp of the manifest file on the server |
| private String mServerTimestamp = null; |
| |
| // the set of cache files we have used -- we use this for cache cleanup. |
| private HashSet<String> mCacheFilesToKeep = new HashSet<String>(); |
| |
| // total # of bytes downloaded (approximate) |
| private long mBytesDownloaded = 0; |
| |
| // total # of bytes read from cache hits (approximate) |
| private long mBytesReadFromCache = 0; |
| |
| public RemoteConferenceDataFetcher(Context context) { |
| mContext = context; |
| mManifestUrl = getManifestUrl(); |
| } |
| |
| /** |
| * Fetches data from the remote server. |
| * |
| * @param refTimestamp The timestamp of the data to use as a reference; if the remote data |
| * is not newer than this timestamp, no data will be downloaded and |
| * this method will return null. |
| * |
| * @return The data downloaded, or null if there is no data to download |
| * @throws IOException if an error occurred during download. |
| */ |
| public String[] fetchConferenceDataIfNewer(String refTimestamp) throws IOException { |
| if (TextUtils.isEmpty(mManifestUrl)) { |
| LOGW(TAG, "Manifest URL is empty (remote sync disabled!)."); |
| return null; |
| } |
| |
| BasicHttpClient httpClient = new BasicHttpClient(); |
| httpClient.setRequestLogger(mQuietLogger); |
| |
| // Only download if data is newer than refTimestamp |
| // Cloud Storage is very picky with the If-Modified-Since format. If it's in a wrong |
| // format, it refuses to serve the file, returning 400 HTTP error. So, if the |
| // refTimestamp is in a wrong format, we simply ignore it. But pay attention to this |
| // warning in the log, because it might mean unnecessary data is being downloaded. |
| if (!TextUtils.isEmpty(refTimestamp)) { |
| if (TimeUtils.isValidFormatForIfModifiedSinceHeader(refTimestamp)) { |
| httpClient.addHeader("If-Modified-Since", refTimestamp); |
| } else { |
| LOGW(TAG, "Could not set If-Modified-Since HTTP header. Potentially downloading " + |
| "unnecessary data. Invalid format of refTimestamp argument: "+refTimestamp); |
| } |
| } |
| |
| HttpResponse response = httpClient.get(mManifestUrl, null); |
| if (response == null) { |
| LOGE(TAG, "Request for manifest returned null response."); |
| throw new IOException("Request for data manifest returned null response."); |
| } |
| |
| int status = response.getStatus(); |
| if (status == HttpURLConnection.HTTP_OK) { |
| LOGD(TAG, "Server returned HTTP_OK, so new data is available."); |
| mServerTimestamp = getLastModified(response); |
| LOGD(TAG, "Server timestamp for new data is: " + mServerTimestamp); |
| String body = response.getBodyAsString(); |
| if (TextUtils.isEmpty(body)) { |
| LOGE(TAG, "Request for manifest returned empty data."); |
| throw new IOException("Error fetching conference data manifest: no data."); |
| } |
| LOGD(TAG, "Manifest "+mManifestUrl+" read, contents: " + body); |
| mBytesDownloaded += body.getBytes().length; |
| return processManifest(body); |
| } else if (status == HttpURLConnection.HTTP_NOT_MODIFIED) { |
| // data on the server is not newer than our data |
| LOGD(TAG, "HTTP_NOT_MODIFIED: data has not changed since " + refTimestamp); |
| return null; |
| } else { |
| LOGE(TAG, "Error fetching conference data: HTTP status " + status); |
| throw new IOException("Error fetching conference data: HTTP status " + status); |
| } |
| } |
| |
| // Returns the timestamp of the data downloaded from the server |
| public String getServerDataTimestamp() { |
| return mServerTimestamp; |
| } |
| |
| /** |
| * Returns the remote manifest file's URL. This is stored as a resource in the app, |
| * but can be overriden by a file in the filesystem for debug purposes. |
| * @return The URL of the remote manifest file. |
| */ |
| private String getManifestUrl() { |
| |
| String manifestUrl = Config.MANIFEST_URL; |
| |
| // check for an override file |
| File urlOverrideFile = new File(mContext.getFilesDir(), URL_OVERRIDE_FILE_NAME); |
| if (urlOverrideFile.exists()) { |
| try { |
| String overrideUrl = FileUtils.readFileAsString(urlOverrideFile).trim(); |
| LOGW(TAG, "Debug URL override active: " + overrideUrl); |
| return overrideUrl; |
| } catch (IOException ex) { |
| return manifestUrl; |
| } |
| } else { |
| return manifestUrl; |
| } |
| } |
| |
| /** |
| * Fetches a file from the cache/network, from an absolute or relative URL. If the |
| * file is available in our cache, we read it from there; if not, we will |
| * download it from the network and cache it. |
| * |
| * @param url The URL to fetch the file from. The URL may be absolute or relative; if |
| * relative, it will be considered to be relative to the manifest URL. |
| * @return The contents of the file. |
| * @throws IOException If an error occurs. |
| */ |
| private String fetchFile(String url) throws IOException { |
| // If this is a relative url, consider it relative to the manifest URL |
| if (!url.contains("://")) { |
| if (TextUtils.isEmpty(mManifestUrl) || !mManifestUrl.contains("/")) { |
| LOGE(TAG, "Could not build relative URL based on manifest URL."); |
| return null; |
| } |
| int i = mManifestUrl.lastIndexOf('/'); |
| url = mManifestUrl.substring(0, i) + "/" + url; |
| } |
| |
| LOGD(TAG, "Attempting to fetch: " + sanitizeUrl(url)); |
| |
| // Check if we have it in our cache first |
| String body = null; |
| try { |
| body = loadFromCache(url); |
| if (!TextUtils.isEmpty(body)) { |
| // cache hit |
| mBytesReadFromCache += body.getBytes().length; |
| mCacheFilesToKeep.add(getCacheKey(url)); |
| return body; |
| } |
| } catch (IOException ex) { |
| ex.printStackTrace(); |
| LOGE(TAG, "IOException getting file from cache."); |
| // proceed anyway to attempt to download it from the network |
| } |
| |
| // We don't have the file on cache, so download it |
| LOGD(TAG, "Cache miss. Downloading from network: " + sanitizeUrl(url)); |
| BasicHttpClient client = new BasicHttpClient(); |
| client.setRequestLogger(mQuietLogger); |
| HttpResponse response = client.get(url, null); |
| |
| if (response == null) { |
| throw new IOException("Request for URL " + sanitizeUrl(url) + " returned null response."); |
| } |
| |
| LOGD(TAG, "HTTP response " + response.getStatus()); |
| if (response.getStatus() == HttpURLConnection.HTTP_OK) { |
| body = response.getBodyAsString(); |
| if (TextUtils.isEmpty(body)) { |
| throw new IOException("Got empty response when attempting to fetch " + |
| sanitizeUrl(url)); |
| } |
| LOGD(TAG, "Successfully downloaded from network: " + sanitizeUrl(url)); |
| mBytesDownloaded += body.getBytes().length; |
| writeToCache(url, body); |
| mCacheFilesToKeep.add(getCacheKey(url)); |
| return body; |
| } else { |
| LOGE(TAG, "Failed to fetch from network: " + sanitizeUrl(url)); |
| throw new IOException("Request for URL " + sanitizeUrl(url) + |
| " failed with HTTP error " + response.getStatus()); |
| } |
| } |
| |
| /** |
| * Returns the cache file where we store our cache of the response of the given URL. |
| * @param url The URL for which to return the cache file. |
| * @return The cache file. |
| */ |
| private File getCacheFile(String url) { |
| String cacheKey = getCacheKey(url); |
| return new File(mContext.getCacheDir() + File.separator + CACHE_DIR + File.separator + |
| cacheKey); |
| } |
| |
| // Creates the cache directory, if it doesn't exist yet |
| private void createCacheDir() throws IOException { |
| File dir = new File(mContext.getCacheDir() + File.separator + CACHE_DIR); |
| if (!dir.exists() && !dir.mkdir()) { |
| throw new IOException("Failed to mkdir: " + dir); |
| } |
| } |
| |
| |
| /** |
| * Loads our cached content corresponding to the given URL. |
| * @param url The URL for which to load the cached response. |
| * @return The cached response corresponding to the URL; or null if the given URL |
| * does not exist in our cache. |
| * @throws IOException If there is an error reading the cache. |
| */ |
| private String loadFromCache(String url) throws IOException { |
| String cacheKey = getCacheKey(url); |
| File cacheFile = getCacheFile(url); |
| if (cacheFile.exists()) { |
| LOGD(TAG, "Cache hit " + cacheKey + " for " + sanitizeUrl(url)); |
| return FileUtils.readFileAsString(cacheFile); |
| } else { |
| LOGD(TAG, "Cache miss " + cacheKey + " for " + sanitizeUrl(url)); |
| return null; |
| } |
| } |
| |
| /** |
| * Writes a file to the cache. |
| * @param url The URL from which the contents were retrieved. |
| * @param body The contents retrieved from the given URL. |
| * @throws IOException If there is a problem writing the file. |
| */ |
| private void writeToCache(String url, String body) throws IOException { |
| String cacheKey = getCacheKey(url); |
| File cacheFile = getCacheFile(url); |
| createCacheDir(); |
| FileUtils.writeFile(body, cacheFile); |
| LOGD(TAG, "Wrote to cache " + cacheKey + " --> " + sanitizeUrl(url)); |
| } |
| |
| /** |
| * Returns the cache key to be used to store the given URL. The cache key is the |
| * file name under which the contents of the URL are stored. |
| * @param url The URL. |
| * @return The cache key (guaranteed to be a valid filename) |
| */ |
| private String getCacheKey(String url) { |
| return HashUtils.computeWeakHash(url.trim()) + String.format("%04x", url.length()); |
| } |
| |
| // Sanitize a URL for logging purposes (only the last component is left visible). |
| private String sanitizeUrl(String url) { |
| int i = url.lastIndexOf('/'); |
| if (i >= 0 && i < url.length()) { |
| return url.substring(0, i).replaceAll("[A-za-z]", "*") + |
| url.substring(i); |
| } |
| else return url.replaceAll("[A-za-z]", "*"); |
| } |
| |
| private static final String MANIFEST_FORMAT = "iosched-json-v1"; |
| |
| /** |
| * Process the data manifest and download data files referenced from it. |
| * @param manifestJson The JSON of the manifest file. |
| * @return The contents of the set of files referenced from the manifest, or null |
| * if none could be retrieved. |
| * @throws IOException If an error occurs while retrieving information. |
| */ |
| private String[] processManifest(String manifestJson) throws IOException { |
| LOGD(TAG, "Processing data manifest, length " + manifestJson.length()); |
| |
| DataManifest manifest = new Gson().fromJson(manifestJson, DataManifest.class); |
| if (manifest.format == null || !manifest.format.equals(MANIFEST_FORMAT)) { |
| LOGE(TAG, "Manifest has invalid format spec: " + manifest.format); |
| throw new IOException("Invalid format spec on manifest:" + manifest.format); |
| } |
| |
| if (manifest.data_files == null || manifest.data_files.length == 0) { |
| LOGW(TAG, "Manifest does not list any files. Nothing done."); |
| return null; |
| } |
| |
| LOGD(TAG, "Manifest lists " + manifest.data_files.length + " data files."); |
| String[] jsons = new String[manifest.data_files.length]; |
| for (int i = 0; i < manifest.data_files.length; i++) { |
| String url = manifest.data_files[i]; |
| LOGD(TAG, "Processing data file: " + sanitizeUrl(url)); |
| jsons[i] = fetchFile(url); |
| if (TextUtils.isEmpty(jsons[i])) { |
| LOGE(TAG, "Failed to fetch data file: " + sanitizeUrl(url)); |
| throw new IOException("Failed to fetch data file " + sanitizeUrl(url)); |
| } |
| } |
| |
| LOGD(TAG, "Got " + jsons.length + " data files."); |
| cleanUpCache(); |
| return jsons; |
| } |
| |
| // Delete unnecessary files from our cache |
| private void cleanUpCache() { |
| LOGD(TAG, "Starting cache cleanup, " + mCacheFilesToKeep.size() + " URLs to keep."); |
| File dir = new File(mContext.getCacheDir() + File.separator + CACHE_DIR); |
| if (!dir.exists()) { |
| LOGD(TAG, "Cleanup complete (there is no cache)."); |
| return; |
| } |
| |
| int deleted = 0, kept = 0; |
| for (File file : dir.listFiles()) { |
| if (mCacheFilesToKeep.contains(file.getName())) { |
| LOGD(TAG, "Cache cleanup: KEEEPING " + file.getName()); |
| ++kept; |
| } else { |
| LOGD(TAG, "Cache cleanup: DELETING " + file.getName()); |
| file.delete(); |
| ++deleted; |
| } |
| } |
| |
| LOGD(TAG, "End of cache cleanup. " + kept + " files kept, " + deleted + " deleted."); |
| } |
| |
| public long getTotalBytesDownloaded() { |
| return mBytesDownloaded; |
| } |
| |
| public long getTotalBytesReadFromCache() { |
| return mBytesReadFromCache; |
| } |
| |
| private String getLastModified(HttpResponse resp) { |
| if (!resp.getHeaders().containsKey("Last-Modified")) { |
| return ""; |
| } |
| |
| List<String> s = resp.getHeaders().get("Last-Modified"); |
| return s.isEmpty() ? "" : s.get(0); |
| } |
| |
| /** |
| * A type of ConsoleRequestLogger that does not log requests and responses. |
| */ |
| private RequestLogger mQuietLogger = new ConsoleRequestLogger(){ |
| @Override |
| public void logRequest(HttpURLConnection uc, Object content) throws IOException { } |
| |
| @Override |
| public void logResponse(HttpResponse res) { } |
| }; |
| |
| |
| } |