/* * 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 com.google.analytics.tracking.android.EasyTracker; import com.google.android.apps.iosched.Config; import com.google.android.apps.iosched.R; import com.google.android.apps.iosched.io.AnnouncementsHandler; import com.google.android.apps.iosched.io.BlocksHandler; import com.google.android.apps.iosched.io.HandlerException; import com.google.android.apps.iosched.io.JSONHandler; import com.google.android.apps.iosched.io.RoomsHandler; import com.google.android.apps.iosched.io.SandboxHandler; import com.google.android.apps.iosched.io.SearchSuggestHandler; import com.google.android.apps.iosched.io.SessionsHandler; import com.google.android.apps.iosched.io.SpeakersHandler; import com.google.android.apps.iosched.io.TracksHandler; import com.google.android.apps.iosched.io.model.EditMyScheduleResponse; import com.google.android.apps.iosched.io.model.ErrorResponse; import com.google.android.apps.iosched.provider.ScheduleContract; import com.google.android.apps.iosched.calendar.SessionCalendarService; import com.google.android.apps.iosched.util.AccountUtils; import com.google.android.apps.iosched.util.UIUtils; import com.google.api.client.googleapis.extensions.android2.auth.GoogleAccountManager; import com.google.gson.Gson; import com.google.gson.JsonObject; import com.google.gson.JsonSyntaxException; import android.accounts.Account; import android.content.ContentProviderOperation; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.content.OperationApplicationException; import android.content.SharedPreferences; import android.content.SyncResult; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.database.Cursor; import android.net.ConnectivityManager; import android.os.RemoteException; import android.preference.PreferenceManager; import java.io.BufferedOutputStream; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; import java.util.ArrayList; import static com.google.android.apps.iosched.util.LogUtils.LOGD; import static com.google.android.apps.iosched.util.LogUtils.LOGI; import static com.google.android.apps.iosched.util.LogUtils.LOGV; import static com.google.android.apps.iosched.util.LogUtils.makeLogTag; /** * 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); static { // Per http://android-developers.blogspot.com/2011/09/androids-http-clients.html if (!UIUtils.hasFroyo()) { System.setProperty("http.keepAlive", "false"); } } public static final int FLAG_SYNC_LOCAL = 0x1; public static final int FLAG_SYNC_REMOTE = 0x2; private static final int LOCAL_VERSION_CURRENT = 19; private Context mContext; private String mAuthToken; private String mUserAgent; public SyncHelper(Context context) { mContext = context; mUserAgent = buildUserAgent(context); } /** * 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 { mAuthToken = AccountUtils.getAuthToken(mContext); 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 batch.addAll(new RoomsHandler(mContext).parse( JSONHandler.loadResourceJson(mContext, R.raw.rooms))); batch.addAll(new BlocksHandler(mContext).parse( JSONHandler.loadResourceJson(mContext, R.raw.common_slots))); batch.addAll(new TracksHandler(mContext).parse( JSONHandler.loadResourceJson(mContext, R.raw.tracks))); batch.addAll(new SpeakersHandler(mContext, true).parse( JSONHandler.loadResourceJson(mContext, R.raw.speakers))); batch.addAll(new SessionsHandler(mContext, true, false).parse( JSONHandler.loadResourceJson(mContext, R.raw.sessions))); batch.addAll(new SandboxHandler(mContext, true).parse( JSONHandler.loadResourceJson(mContext, R.raw.sandbox))); batch.addAll(new SearchSuggestHandler(mContext).parse( JSONHandler.loadResourceJson(mContext, R.raw.search_suggest))); prefs.edit().putInt("local_data_version", LOCAL_VERSION_CURRENT).commit(); if (syncResult != null) { ++syncResult.stats.numUpdates; ++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 { boolean auth = !UIUtils.isGoogleTV(mContext) && AccountUtils.isAuthenticated(mContext); final long startRemote = System.currentTimeMillis(); LOGI(TAG, "Remote syncing speakers"); batch.addAll(executeGet(Config.GET_ALL_SPEAKERS_URL, new SpeakersHandler(mContext, false), auth)); LOGI(TAG, "Remote syncing sessions"); batch.addAll(executeGet(Config.GET_ALL_SESSIONS_URL, new SessionsHandler(mContext, false, mAuthToken != null), auth)); LOGI(TAG, "Remote syncing sandbox"); batch.addAll(executeGet(Config.GET_SANDBOX_URL, new SandboxHandler(mContext, false), auth)); LOGI(TAG, "Remote syncing announcements"); batch.addAll(executeGet(Config.GET_ALL_ANNOUNCEMENTS_URL, new AnnouncementsHandler(mContext, false), auth)); // GET_ALL_SESSIONS covers the functionality GET_MY_SCHEDULE provides here. LOGD(TAG, "Remote sync took " + (System.currentTimeMillis() - startRemote) + "ms"); if (syncResult != null) { ++syncResult.stats.numUpdates; ++syncResult.stats.numEntries; } EasyTracker.getTracker().dispatch(); } catch (HandlerException.UnauthorizedException e) { LOGI(TAG, "Unauthorized; getting a new auth token.", e); if (syncResult != null) { ++syncResult.stats.numAuthExceptions; } AccountUtils.invalidateAuthToken(mContext); AccountUtils.tryAuthenticateWithErrorNotification(mContext, null, new Account(AccountUtils.getChosenAccountName(mContext), GoogleAccountManager.ACCOUNT_TYPE)); } // all other IOExceptions are thrown } try { // Apply all queued up remaining batch operations (only remote content at this point). resolver.applyBatch(ScheduleContract.CONTENT_AUTHORITY, batch); // 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); } if (UIUtils.hasICS()) { LOGD(TAG, "Done with sync'ing conference data. Starting to sync " + "session with Calendar."); syncCalendar(); } } private void syncCalendar() { Intent intent = new Intent(SessionCalendarService.ACTION_UPDATE_ALL_SESSIONS_CALENDAR); intent.setClass(mContext, SessionCalendarService.class); mContext.startService(intent); } /** * Build and return a user-agent string that can identify this application * to remote servers. Contains the package name and version code. */ private static String buildUserAgent(Context context) { String versionName = "unknown"; int versionCode = 0; try { final PackageInfo info = context.getPackageManager() .getPackageInfo(context.getPackageName(), 0); versionName = info.versionName; versionCode = info.versionCode; } catch (PackageManager.NameNotFoundException ignored) { } return context.getPackageName() + "/" + versionName + " (" + versionCode + ") (gzip)"; } public void addOrRemoveSessionFromSchedule(Context context, String sessionId, boolean inSchedule) throws IOException { mAuthToken = AccountUtils.getAuthToken(mContext); JsonObject starredSession = new JsonObject(); starredSession.addProperty("sessionid", sessionId); byte[] postJsonBytes = new Gson().toJson(starredSession).getBytes(); URL url = new URL(Config.EDIT_MY_SCHEDULE_URL + (inSchedule ? "add" : "remove")); HttpURLConnection urlConnection = (HttpURLConnection) url.openConnection(); urlConnection.setRequestProperty("User-Agent", mUserAgent); urlConnection.setRequestProperty("Content-Type", "application/json"); urlConnection.setRequestProperty("Authorization", "Bearer " + mAuthToken); urlConnection.setDoOutput(true); urlConnection.setFixedLengthStreamingMode(postJsonBytes.length); LOGD(TAG, "Posting to URL: " + url); OutputStream out = new BufferedOutputStream(urlConnection.getOutputStream()); out.write(postJsonBytes); out.flush(); urlConnection.connect(); throwErrors(urlConnection); String json = readInputStream(urlConnection.getInputStream()); EditMyScheduleResponse response = new Gson().fromJson(json, EditMyScheduleResponse.class); if (!response.success) { String responseMessageLower = (response.message != null) ? response.message.toLowerCase() : ""; if (responseMessageLower.contains("no profile")) { throw new HandlerException.NoDevsiteProfileException(); } } } private ArrayList<ContentProviderOperation> executeGet(String urlString, JSONHandler handler, boolean authenticated) throws IOException { LOGD(TAG, "Requesting URL: " + urlString); URL url = new URL(urlString); HttpURLConnection urlConnection = (HttpURLConnection) url.openConnection(); urlConnection.setRequestProperty("User-Agent", mUserAgent); if (authenticated && mAuthToken != null) { urlConnection.setRequestProperty("Authorization", "Bearer " + mAuthToken); } urlConnection.connect(); throwErrors(urlConnection); String response = readInputStream(urlConnection.getInputStream()); LOGV(TAG, "HTTP response: " + response); return handler.parse(response); } private void throwErrors(HttpURLConnection urlConnection) throws IOException { final int status = urlConnection.getResponseCode(); if (status < 200 || status >= 300) { String errorMessage = null; try { String errorContent = readInputStream(urlConnection.getErrorStream()); LOGV(TAG, "Error content: " + errorContent); ErrorResponse errorResponse = new Gson().fromJson( errorContent, ErrorResponse.class); errorMessage = errorResponse.error.message; } catch (IOException ignored) { } catch (JsonSyntaxException ignored) { } String exceptionMessage = "Error response " + status + " " + urlConnection.getResponseMessage() + (errorMessage == null ? "" : (": " + errorMessage)) + " for " + urlConnection.getURL(); // TODO: the API should return 401, and we shouldn't have to parse the message throw (errorMessage != null && errorMessage.toLowerCase().contains("auth")) ? new HandlerException.UnauthorizedException(exceptionMessage) : new HandlerException(exceptionMessage); } } private static String readInputStream(InputStream inputStream) throws IOException { BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream)); String responseLine; StringBuilder responseBuilder = new StringBuilder(); while ((responseLine = bufferedReader.readLine()) != null) { responseBuilder.append(responseLine); } return responseBuilder.toString(); } private boolean isOnline() { ConnectivityManager cm = (ConnectivityManager) mContext.getSystemService( Context.CONNECTIVITY_SERVICE); return cm.getActiveNetworkInfo() != null && cm.getActiveNetworkInfo().isConnectedOrConnecting(); } }