/*
* 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.*;
import android.net.ConnectivityManager;
import android.os.Bundle;
import android.support.annotation.Nullable;
import com.google.samples.apps.iosched.BuildConfig;
import com.google.samples.apps.iosched.Config;
import com.google.samples.apps.iosched.feedback.FeedbackApiHelper;
import com.google.samples.apps.iosched.feedback.FeedbackSyncHelper;
import com.google.samples.apps.iosched.provider.ScheduleContract;
import com.google.samples.apps.iosched.service.DataBootstrapService;
import com.google.samples.apps.iosched.service.SessionAlarmService;
import com.google.samples.apps.iosched.service.SessionCalendarService;
import com.google.samples.apps.iosched.settings.SettingsUtils;
import com.google.samples.apps.iosched.sync.account.Account;
import com.google.samples.apps.iosched.sync.userdata.AbstractUserDataSyncHelper;
import com.google.samples.apps.iosched.sync.userdata.UserDataSyncHelperFactory;
import com.google.samples.apps.iosched.util.AccountUtils;
import com.google.samples.apps.iosched.util.TimeUtils;
import com.turbomanage.httpclient.BasicHttpClient;
import com.turbomanage.httpclient.HttpResponse;
import com.turbomanage.httpclient.RequestLogger;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;
import static com.google.samples.apps.iosched.util.LogUtils.*;
/**
* A helper class for dealing with conference data synchronization. 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);
private Context mContext;
private ConferenceDataHandler mConferenceDataHandler;
private RemoteConferenceDataFetcher mRemoteDataFetcher;
private BasicHttpClient mHttpClient;
/**
*
* @param context Can be Application, Activity or Service context.
*/
public SyncHelper(Context context) {
mContext = context;
mConferenceDataHandler = new ConferenceDataHandler(mContext);
mRemoteDataFetcher = new RemoteConferenceDataFetcher(mContext);
mHttpClient = new BasicHttpClient();
if (!BuildConfig.DEBUG) {
mHttpClient.setRequestLogger(new MinimalRequestLogger());
}
}
public static void requestManualSync() {
requestManualSync(false);
}
public static void requestManualSync(boolean userDataSyncOnly) {
LOGD(TAG, "Requesting manual sync for account. userDataSyncOnly=" + userDataSyncOnly);
android.accounts.Account account = Account.getAccount();
Bundle b = new Bundle();
b.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true);
b.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true);
if (userDataSyncOnly) {
b.putBoolean(SyncAdapter.EXTRA_SYNC_USER_DATA_ONLY, true);
}
ContentResolver
.setSyncAutomatically(account, ScheduleContract.CONTENT_AUTHORITY, true);
ContentResolver.setIsSyncable(account, ScheduleContract.CONTENT_AUTHORITY, 1);
boolean pending = ContentResolver.isSyncPending(account, ScheduleContract.CONTENT_AUTHORITY);
if (pending) {
LOGD(TAG, "Warning: sync is PENDING. Will cancel.");
}
boolean active = ContentResolver.isSyncActive(account, ScheduleContract.CONTENT_AUTHORITY);
if (active) {
LOGD(TAG, "Warning: sync is ACTIVE. Will cancel.");
}
if (pending || active) {
LOGD(TAG, "Cancelling previously pending/active sync.");
ContentResolver.cancelSync(account, ScheduleContract.CONTENT_AUTHORITY);
}
LOGD(TAG, "Requesting sync now.");
ContentResolver.requestSync(account, ScheduleContract.CONTENT_AUTHORITY, b);
}
/**
* Attempts to perform data synchronization. There are 3 types of data: conference, user
* schedule and user feedback.
* <p />
* The conference data sync is handled by {@link RemoteConferenceDataFetcher}. For more details
* about conference data, refer to the documentation at
* https://github.com/google/iosched/blob/master/doc/SYNC.md. The user schedule data sync is
* handled by {@link AbstractUserDataSyncHelper}. The user feedback sync is handled by
* {@link FeedbackSyncHelper}.
*
*
* @param syncResult The sync result object to update with statistics.
* @param extras Specifies additional information about the sync. This must contain key
* {@code SyncAdapter.EXTRA_SYNC_USER_DATA_ONLY} with boolean value
* @return true if the sync changed the data.
*/
public boolean performSync(@Nullable SyncResult syncResult, Bundle extras) {
android.accounts.Account account = Account.getAccount();
boolean dataChanged = false;
if (!SettingsUtils.isDataBootstrapDone(mContext)) {
LOGD(TAG, "Sync aborting (data bootstrap not done yet)");
// Start the bootstrap process so that the next time sync is called,
// it is already bootstrapped.
DataBootstrapService.startDataBootstrapIfNecessary(mContext);
return false;
}
final boolean userDataScheduleOnly = extras
.getBoolean(SyncAdapter.EXTRA_SYNC_USER_DATA_ONLY, false);
LOGI(TAG, "Performing sync for account: " + account);
SettingsUtils.markSyncAttemptedNow(mContext);
long opStart;
long syncDuration, choresDuration;
opStart = System.currentTimeMillis();
// Sync consists of 1 or more of these operations. We try them one by one and tolerate
// individual failures on each.
final int OP_CONFERENCE_DATA_SYNC = 0;
final int OP_USER_SCHEDULE_DATA_SYNC = 1;
final int OP_USER_FEEDBACK_DATA_SYNC = 2;
int[] opsToPerform = userDataScheduleOnly ?
new int[]{OP_USER_SCHEDULE_DATA_SYNC} :
new int[]{OP_CONFERENCE_DATA_SYNC, OP_USER_SCHEDULE_DATA_SYNC,
OP_USER_FEEDBACK_DATA_SYNC};
for (int op : opsToPerform) {
try {
switch (op) {
case OP_CONFERENCE_DATA_SYNC:
dataChanged |= doConferenceDataSync();
break;
case OP_USER_SCHEDULE_DATA_SYNC:
dataChanged |= doUserDataSync(syncResult, account.name);
break;
case OP_USER_FEEDBACK_DATA_SYNC:
// User feedback data sync is an outgoing sync only so not affecting
// {@code dataChanged} value.
doUserFeedbackDataSync();
break;
}
} catch (AuthException ex) {
syncResult.stats.numAuthExceptions++;
// If we have a token, try to refresh it.
if (AccountUtils.hasToken(mContext, account.name)) {
AccountUtils.refreshAuthToken(mContext);
} else {
LOGW(TAG, "No auth token yet for this account. Skipping remote sync.");
}
} catch (Throwable throwable) {
throwable.printStackTrace();
LOGE(TAG, "Error performing remote sync.");
increaseIoExceptions(syncResult);
}
}
syncDuration = System.currentTimeMillis() - opStart;
// If data has changed, there are a few chores we have to do.
opStart = System.currentTimeMillis();
if (dataChanged) {
try {
performPostSyncChores(mContext);
} catch (Throwable throwable) {
throwable.printStackTrace();
LOGE(TAG, "Error performing post sync chores.");
}
}
choresDuration = System.currentTimeMillis() - opStart;
int operations = mConferenceDataHandler.getContentProviderOperationsDone();
if (syncResult != null && syncResult.stats != null) {
syncResult.stats.numEntries += operations;
syncResult.stats.numUpdates += operations;
}
if (dataChanged) {
long totalDuration = choresDuration + syncDuration;
LOGD(TAG, "SYNC STATS:\n" +
" * Account synced: " + (account == null ? "null" : account.name) + "\n" +
" * Content provider operations: " + operations + "\n" +
" * Sync took: " + syncDuration + "ms\n" +
" * Post-sync chores took: " + choresDuration + "ms\n" +
" * Total time: " + totalDuration + "ms\n" +
" * Total data read from cache: \n" +
(mRemoteDataFetcher.getTotalBytesReadFromCache() / 1024) + "kB\n" +
" * Total data downloaded: \n" +
(mRemoteDataFetcher.getTotalBytesDownloaded() / 1024) + "kB");
}
LOGI(TAG, "End of sync (" + (dataChanged ? "data changed" : "no data change") + ")");
updateSyncInterval(mContext);
return dataChanged;
}
public static void performPostSyncChores(final Context context) {
// Update search index.
LOGD(TAG, "Updating search index.");
context.getContentResolver().update(ScheduleContract.SearchIndex.CONTENT_URI,
new ContentValues(), null, null);
// Sync calendar.
LOGD(TAG, "Session data changed. Syncing starred sessions with Calendar.");
syncCalendar(context);
}
private static void syncCalendar(Context context) {
Intent intent = new Intent(SessionCalendarService.ACTION_UPDATE_ALL_SESSIONS_CALENDAR);
intent.setClass(context, SessionCalendarService.class);
context.startService(intent);
}
private void doUserFeedbackDataSync() {
LOGD(TAG, "Syncing feedback");
new FeedbackSyncHelper(mContext, new FeedbackApiHelper(mHttpClient,
BuildConfig.FEEDBACK_API_ENDPOINT)).sync();
}
/**
* Checks if the remote server has new conference data that we need to import. If so, download
* the new data and import it into the database.
*
* @return Whether or not data was changed.
* @throws IOException if there is a problem downloading or importing the data.
*/
private boolean doConferenceDataSync() throws IOException {
if (!isOnline()) {
LOGD(TAG, "Not attempting remote sync because device is OFFLINE");
return false;
}
LOGD(TAG, "Starting remote sync.");
// Fetch the remote data files via RemoteConferenceDataFetcher.
String[] dataFiles = mRemoteDataFetcher.fetchConferenceDataIfNewer(
mConferenceDataHandler.getDataTimestamp());
if (dataFiles != null) {
LOGI(TAG, "Applying remote data.");
// Save the remote data to the database.
mConferenceDataHandler.applyConferenceData(dataFiles,
mRemoteDataFetcher.getServerDataTimestamp(), true);
LOGI(TAG, "Done applying remote data.");
// Mark that conference data sync has succeeded.
SettingsUtils.markSyncSucceededNow(mContext);
return true;
} else {
// No data to process (everything is up to date).
// Mark that conference data sync succeeded.
SettingsUtils.markSyncSucceededNow(mContext);
return false;
}
}
/**
* Checks if there are changes on User's Data to sync with/from remote AppData folder.
*
* @return Whether or not data was changed.
* @throws IOException if there is a problem uploading the data.
*/
private boolean doUserDataSync(SyncResult syncResult, String accountName) throws IOException {
if (!isOnline()) {
LOGD(TAG, "Not attempting userdata sync because device is OFFLINE");
return false;
}
LOGD(TAG, "Starting user data sync.");
AbstractUserDataSyncHelper helper = UserDataSyncHelperFactory.buildSyncHelper(
mContext, accountName);
boolean modified = helper.sync();
if (modified) {
// Schedule notifications for the starred sessions.
Intent scheduleIntent = new Intent(
SessionAlarmService.ACTION_SCHEDULE_ALL_STARRED_BLOCKS,
null, mContext, SessionAlarmService.class);
mContext.startService(scheduleIntent);
}
syncResult.stats.numIoExceptions += helper.getIoExcpetions();
return modified;
}
private boolean isOnline() {
ConnectivityManager cm = (ConnectivityManager) mContext.getSystemService(
Context.CONNECTIVITY_SERVICE);
return cm.getActiveNetworkInfo() != null &&
cm.getActiveNetworkInfo().isConnectedOrConnecting();
}
private void increaseIoExceptions(SyncResult syncResult) {
if (syncResult != null && syncResult.stats != null) {
++syncResult.stats.numIoExceptions;
}
}
public static class AuthException extends RuntimeException {
}
private static long calculateRecommendedSyncInterval(final Context context) {
long now = TimeUtils.getCurrentTime(context);
long aroundConferenceStart = Config.CONFERENCE_START_MILLIS
- Config.AUTO_SYNC_AROUND_CONFERENCE_THRESH;
if (now < aroundConferenceStart) {
return Config.AUTO_SYNC_INTERVAL_LONG_BEFORE_CONFERENCE;
} else if (now <= Config.CONFERENCE_END_MILLIS) {
return Config.AUTO_SYNC_INTERVAL_AROUND_CONFERENCE;
} else {
return Config.AUTO_SYNC_INTERVAL_AFTER_CONFERENCE;
}
}
public static void updateSyncInterval(final Context context) {
android.accounts.Account account = Account.getAccount();
LOGD(TAG, "Checking sync interval");
long recommended = calculateRecommendedSyncInterval(context);
long current = SettingsUtils.getCurSyncInterval(context);
LOGD(TAG, "Recommended sync interval " + recommended + ", current " + current);
if (recommended != current) {
LOGD(TAG,
"Setting up sync for account, interval " + recommended + "ms");
ContentResolver.setIsSyncable(account, ScheduleContract.CONTENT_AUTHORITY, 1);
ContentResolver.setSyncAutomatically(account, ScheduleContract.CONTENT_AUTHORITY, true);
if (recommended <= 0L) { // Disable periodic sync.
ContentResolver.removePeriodicSync(account, ScheduleContract.CONTENT_AUTHORITY,
new Bundle());
} else {
ContentResolver.addPeriodicSync(account, ScheduleContract.CONTENT_AUTHORITY,
new Bundle(), recommended / 1000L);
}
SettingsUtils.setCurSyncInterval(context, recommended);
} else {
LOGD(TAG, "No need to update sync interval.");
}
}
public static class MinimalRequestLogger implements RequestLogger {
@Override
public boolean isLoggingEnabled() {
return true;
}
@Override
public void log(final String s) { }
@Override
public void logRequest(final HttpURLConnection urlConnection, final Object o)
throws IOException {
try {
URL url = urlConnection.getURL();
LOGW(TAG, "HTTPRequest to " + url.getHost());
} catch (Throwable e) {
LOGI(TAG, "Exception while logging http request.");
}
}
@Override
public void logResponse(final HttpResponse httpResponse) {
try {
URL url = new URL(httpResponse.getUrl());
LOGW(TAG, "HTTPResponse from " + url.getHost() + " had return status " + httpResponse.getStatus());
} catch (Throwable e) {
LOGI(TAG, "Exception while logging http response.");
}
}
}
}