/*
* Copyright 2012 Google Inc.
*
* 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.android.apps.iosched.sync;
import android.accounts.Account;
import android.content.*;
import android.database.Cursor;
import android.net.ConnectivityManager;
import android.os.Bundle;
import android.os.RemoteException;
import android.preference.PreferenceManager;
import com.google.analytics.tracking.android.GAServiceManager;
import com.google.android.apps.iosched.Config;
import com.google.android.apps.iosched.R;
import com.google.android.apps.iosched.io.*;
import com.google.android.apps.iosched.io.map.model.Tile;
import com.google.android.apps.iosched.provider.ScheduleContract;
import com.google.android.apps.iosched.util.*;
import com.google.api.client.extensions.android.json.AndroidJsonFactory;
import com.google.api.client.googleapis.auth.oauth2.GoogleCredential;
import com.google.api.client.googleapis.json.GoogleJsonResponseException;
import com.google.api.client.googleapis.services.CommonGoogleClientRequestInitializer;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.JsonFactory;
import com.google.api.services.googledevelopers.Googledevelopers;
import java.io.*;
import java.net.HttpURLConnection;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import com.larvalabs.svgandroid.SVG;
import com.larvalabs.svgandroid.SVGParseException;
import com.larvalabs.svgandroid.SVGParser;
import com.turbomanage.httpclient.BasicHttpClient;
import com.turbomanage.httpclient.ConsoleRequestLogger;
import com.turbomanage.httpclient.HttpResponse;
import com.turbomanage.httpclient.RequestLogger;
import static com.google.android.apps.iosched.util.LogUtils.*;
/**
* A helper class for dealing with sync and other remote persistence operations.
* All operations occur on the thread they're called from, so it's best to wrap
* calls in an {@link android.os.AsyncTask}, or better yet, a
* {@link android.app.Service}.
*/
public class SyncHelper {
private static final String TAG = makeLogTag(SyncHelper.class);
public static final int FLAG_SYNC_LOCAL = 0x1;
public static final int FLAG_SYNC_REMOTE = 0x2;
private static final int LOCAL_VERSION_CURRENT = 25;
private static final String LOCAL_MAPVERSION_CURRENT = "\"vlh7Ig\"";
private Context mContext;
public SyncHelper(Context context) {
mContext = context;
}
public static void requestManualSync(Account mChosenAccount) {
Bundle b = new Bundle();
b.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true);
ContentResolver.requestSync(
mChosenAccount,
ScheduleContract.CONTENT_AUTHORITY, b);
}
/**
* Loads conference information (sessions, rooms, tracks, speakers, etc.)
* from a local static cache data and then syncs down data from the
* Conference API.
*
* @param syncResult Optional {@link SyncResult} object to populate.
* @throws IOException
*/
public void performSync(SyncResult syncResult, int flags) throws IOException {
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(mContext);
final int localVersion = prefs.getInt("local_data_version", 0);
// Bulk of sync work, performed by executing several fetches from
// local and online sources.
final ContentResolver resolver = mContext.getContentResolver();
ArrayList<ContentProviderOperation> batch = new ArrayList<ContentProviderOperation>();
LOGI(TAG, "Performing sync");
if ((flags & FLAG_SYNC_LOCAL) != 0) {
final long startLocal = System.currentTimeMillis();
final boolean localParse = localVersion < LOCAL_VERSION_CURRENT;
LOGD(TAG, "found localVersion=" + localVersion + " and LOCAL_VERSION_CURRENT="
+ LOCAL_VERSION_CURRENT);
// Only run local sync if there's a newer version of data available
// than what was last locally-sync'd.
if (localParse) {
// Load static local data
LOGI(TAG, "Local syncing rooms");
batch.addAll(new RoomsHandler(mContext).parse(
JSONHandler.parseResource(mContext, R.raw.rooms)));
LOGI(TAG, "Local syncing blocks");
batch.addAll(new BlocksHandler(mContext).parse(
JSONHandler.parseResource(mContext, R.raw.common_slots)));
LOGI(TAG, "Local syncing tracks");
batch.addAll(new TracksHandler(mContext).parse(
JSONHandler.parseResource(mContext, R.raw.tracks)));
LOGI(TAG, "Local syncing speakers");
batch.addAll(new SpeakersHandler(mContext).parseString(
JSONHandler.parseResource(mContext, R.raw.speakers)));
LOGI(TAG, "Local syncing sessions");
batch.addAll(new SessionsHandler(mContext).parseString(
JSONHandler.parseResource(mContext, R.raw.sessions),
JSONHandler.parseResource(mContext, R.raw.session_tracks)));
LOGI(TAG, "Local syncing search suggestions");
batch.addAll(new SearchSuggestHandler(mContext).parse(
JSONHandler.parseResource(mContext, R.raw.search_suggest)));
LOGI(TAG, "Local syncing map");
MapPropertyHandler mapHandler = new MapPropertyHandler(mContext);
batch.addAll(mapHandler.parse(
JSONHandler.parseResource(mContext, R.raw.map)));
//need to sync tile files before data is updated in content provider
syncMapTiles(mapHandler.getTiles());
prefs.edit().putInt("local_data_version", LOCAL_VERSION_CURRENT).commit();
prefs.edit().putString("local_mapdata_version", LOCAL_MAPVERSION_CURRENT).commit();
if (syncResult != null) {
++syncResult.stats.numUpdates; // TODO: better way of indicating progress?
++syncResult.stats.numEntries;
}
}
LOGD(TAG, "Local sync took " + (System.currentTimeMillis() - startLocal) + "ms");
try {
// Apply all queued up batch operations for local data.
resolver.applyBatch(ScheduleContract.CONTENT_AUTHORITY, batch);
} catch (RemoteException e) {
throw new RuntimeException("Problem applying batch operation", e);
} catch (OperationApplicationException e) {
throw new RuntimeException("Problem applying batch operation", e);
}
batch = new ArrayList<ContentProviderOperation>();
}
if ((flags & FLAG_SYNC_REMOTE) != 0 && isOnline()) {
try {
Googledevelopers conferenceAPI = getConferenceAPIClient();
final long startRemote = System.currentTimeMillis();
LOGI(TAG, "Remote syncing announcements");
batch.addAll(new AnnouncementsFetcher(mContext).fetchAndParse());
LOGI(TAG, "Remote syncing speakers");
batch.addAll(new SpeakersHandler(mContext).fetchAndParse(conferenceAPI));
LOGI(TAG, "Remote syncing sessions");
batch.addAll(new SessionsHandler(mContext).fetchAndParse(conferenceAPI));
// Map sync
batch.addAll(remoteSyncMapData(Config.GET_MAP_URL,prefs));
LOGD(TAG, "Remote sync took " + (System.currentTimeMillis() - startRemote) + "ms");
if (syncResult != null) {
++syncResult.stats.numUpdates; // TODO: better way of indicating progress?
++syncResult.stats.numEntries;
}
GAServiceManager.getInstance().dispatch();
// Sync feedback stuff
LOGI(TAG, "Syncing session feedback");
batch.addAll(new FeedbackHandler(mContext).uploadNew(conferenceAPI));
} catch (GoogleJsonResponseException e) {
if (e.getStatusCode() == 401) {
LOGI(TAG, "Unauthorized; getting a new auth token.", e);
if (syncResult != null) {
++syncResult.stats.numAuthExceptions;
}
AccountUtils.refreshAuthToken(mContext);
}
}
// all other IOExceptions are thrown
LOGI(TAG, "Sync complete");
}
try {
// Apply all queued up remaining batch operations (only remote content at this point).
resolver.applyBatch(ScheduleContract.CONTENT_AUTHORITY, batch);
// Update search index
resolver.update(ScheduleContract.SearchIndex.CONTENT_URI, new ContentValues(),
null, null);
// Delete empty blocks
Cursor emptyBlocksCursor = resolver.query(ScheduleContract.Blocks.CONTENT_URI,
new String[]{ScheduleContract.Blocks.BLOCK_ID,ScheduleContract.Blocks.SESSIONS_COUNT},
ScheduleContract.Blocks.EMPTY_SESSIONS_SELECTION, null, null);
batch = new ArrayList<ContentProviderOperation>();
int numDeletedEmptyBlocks = 0;
while (emptyBlocksCursor.moveToNext()) {
batch.add(ContentProviderOperation
.newDelete(ScheduleContract.Blocks.buildBlockUri(
emptyBlocksCursor.getString(0)))
.build());
++numDeletedEmptyBlocks;
}
emptyBlocksCursor.close();
resolver.applyBatch(ScheduleContract.CONTENT_AUTHORITY, batch);
LOGD(TAG, "Deleted " + numDeletedEmptyBlocks + " empty session blocks.");
} catch (RemoteException e) {
throw new RuntimeException("Problem applying batch operation", e);
} catch (OperationApplicationException e) {
throw new RuntimeException("Problem applying batch operation", e);
}
}
public void addOrRemoveSessionFromSchedule(Context context, String sessionId,
boolean inSchedule) throws IOException {
LOGI(TAG, "Updating session on user schedule: " + sessionId);
Googledevelopers conferenceAPI = getConferenceAPIClient();
try {
sendScheduleUpdate(conferenceAPI, context, sessionId, inSchedule);
} catch (GoogleJsonResponseException e) {
if (e.getDetails().getCode() == 401) {
LOGI(TAG, "Unauthorized; getting a new auth token.", e);
AccountUtils.refreshAuthToken(mContext);
// Try request one more time with new credentials before giving up
conferenceAPI = getConferenceAPIClient();
sendScheduleUpdate(conferenceAPI, context, sessionId, inSchedule);
}
}
}
private void sendScheduleUpdate(Googledevelopers conferenceAPI,
Context context, String sessionId, boolean inSchedule) throws IOException {
if (inSchedule) {
conferenceAPI.users().events().sessions().update(Config.EVENT_ID, sessionId, null).execute();
} else {
conferenceAPI.users().events().sessions().delete(Config.EVENT_ID, sessionId).execute();
}
}
private ArrayList<ContentProviderOperation> remoteSyncMapData(String urlString,
SharedPreferences preferences) throws IOException {
final String localVersion = preferences.getString("local_mapdata_version", null);
ArrayList<ContentProviderOperation> batch = Lists.newArrayList();
BasicHttpClient httpClient = new BasicHttpClient();
httpClient.setRequestLogger(mQuietLogger);
httpClient.addHeader("If-None-Match", localVersion);
LOGD(TAG,"Local map version: "+localVersion);
HttpResponse response = httpClient.get(urlString, null);
final int status = response.getStatus();
if (status == HttpURLConnection.HTTP_OK) {
// Data has been updated, otherwise would have received HTTP_NOT_MODIFIED
LOGI(TAG, "Remote syncing map data");
final List<String> etag = response.getHeaders().get("ETag");
if (etag != null && etag.size() > 0) {
MapPropertyHandler handler = new MapPropertyHandler(mContext);
batch.addAll(handler.parse(response.getBodyAsString()));
syncMapTiles(handler.getTiles());
// save new etag as version
preferences.edit().putString("local_mapdata_version", etag.get(0)).commit();
}
} //else: no update
return batch;
}
private boolean isOnline() {
ConnectivityManager cm = (ConnectivityManager) mContext.getSystemService(
Context.CONNECTIVITY_SERVICE);
return cm.getActiveNetworkInfo() != null &&
cm.getActiveNetworkInfo().isConnectedOrConnecting();
}
/**
* Synchronise the map overlay files either from the local assets (if available) or from a remote url.
*
* @param collection Set of tiles containing a local filename and remote url.
* @throws IOException
*/
private void syncMapTiles(Collection<Tile> collection) throws IOException, SVGParseException {
//keep track of used files, unused files are removed
ArrayList<String> usedTiles = Lists.newArrayList();
for(Tile tile : collection){
final String filename = tile.filename;
final String url = tile.url;
usedTiles.add(filename);
if (!MapUtils.hasTile(mContext, filename)) {
// copy or download the tile if it is not stored yet
if (MapUtils.hasTileAsset(mContext, filename)) {
// file already exists as an asset, copy it
MapUtils.copyTileAsset(mContext, filename);
} else {
// download the file
File tileFile = MapUtils.getTileFile(mContext, filename);
BasicHttpClient httpClient = new BasicHttpClient();
httpClient.setRequestLogger(mQuietLogger);
HttpResponse httpResponse = httpClient.get(url, null);
writeFile(httpResponse.getBody(), tileFile);
// ensure the file is valid SVG
InputStream is = new FileInputStream(tileFile);
SVG svg = SVGParser.getSVGFromInputStream(is);
is.close();
}
}
}
MapUtils.removeUnusedTiles(mContext, usedTiles);
}
/**
* Write the byte array directly to a file.
* @throws IOException
*/
private void writeFile(byte[] data, File file) throws IOException {
BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(file, false));
bos.write(data);
bos.close();
}
/**
* 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) { }
};
private Googledevelopers getConferenceAPIClient() {
HttpTransport httpTransport = new NetHttpTransport();
JsonFactory jsonFactory = new AndroidJsonFactory();
GoogleCredential credential =
new GoogleCredential().setAccessToken(AccountUtils.getAuthToken(mContext));
// Note: The Googledevelopers API is unique, in that it requires an API key in addition to the client
// ID normally embedded an an OAuth token. Most apps will use one or the other.
return new Googledevelopers.Builder(httpTransport, jsonFactory, null)
.setApplicationName(NetUtils.getUserAgent(mContext))
.setGoogleClientRequestInitializer(new
CommonGoogleClientRequestInitializer(Config.API_KEY))
.setHttpRequestInitializer(credential)
.build();
}
}