blob: 38f4c02c377dfa123f2a962af2464f5c48761ff6 [file] [log] [blame]
/*
* 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) { }
};
}