// Copyright 2015 The Project Buendia Authors
//
// 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 distrib-
// uted 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
// specific language governing permissions and limitations under the License.
package org.projectbuendia.client.sync;
import android.accounts.Account;
import android.content.AbstractThreadedSyncAdapter;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.OperationApplicationException;
import android.content.SyncResult;
import android.database.Cursor;
import android.os.Bundle;
import android.os.RemoteException;
import android.support.annotation.Nullable;
import android.support.annotation.StringRes;
import android.util.TimingLogger;
import org.joda.time.Instant;
import org.projectbuendia.client.App;
import org.projectbuendia.client.R;
import org.projectbuendia.client.providers.BuendiaProvider;
import org.projectbuendia.client.providers.Contracts;
import org.projectbuendia.client.providers.Contracts.Misc;
import org.projectbuendia.client.providers.Contracts.SyncTokens;
import org.projectbuendia.client.providers.SQLiteDatabaseTransactionHelper;
import org.projectbuendia.client.sync.controllers.ChartsSyncPhaseRunnable;
import org.projectbuendia.client.sync.controllers.ConceptsSyncPhaseRunnable;
import org.projectbuendia.client.sync.controllers.FormsSyncPhaseRunnable;
import org.projectbuendia.client.sync.controllers.LocationsSyncPhaseRunnable;
import org.projectbuendia.client.sync.controllers.ObservationsSyncPhaseRunnable;
import org.projectbuendia.client.sync.controllers.OrdersSyncPhaseRunnable;
import org.projectbuendia.client.sync.controllers.PatientsSyncPhaseRunnable;
import org.projectbuendia.client.sync.controllers.SyncPhaseRunnable;
import org.projectbuendia.client.sync.controllers.UsersSyncPhaseRunnable;
import org.projectbuendia.client.utils.Logger;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.CancellationException;
/** Global sync adapter for syncing all client side database caches. */
public class SyncAdapter extends AbstractThreadedSyncAdapter {
private static final Logger LOG = Logger.create();
/** Named used during the sync process for SQL savepoints. */
private static final String SYNC_SAVEPOINT_NAME = "SYNC_SAVEPOINT";
/** Content resolver, for performing database operations. */
private final ContentResolver mContentResolver;
/** Tracks whether the sync has been canceled. */
private boolean mIsSyncCanceled = false;
/**
* Keys in the extras bundle used to select which sync phases to do.
* Select a phase by setting a boolean value of true for the appropriate key.
*/
public enum SyncPhase {
SYNC_USERS(R.string.syncing_users, new UsersSyncPhaseRunnable()),
SYNC_LOCATIONS(R.string.syncing_locations, new LocationsSyncPhaseRunnable()),
SYNC_CHART_ITEMS(R.string.syncing_charts, new ChartsSyncPhaseRunnable()),
SYNC_CONCEPTS(R.string.syncing_concepts, new ConceptsSyncPhaseRunnable()),
SYNC_PATIENTS(R.string.syncing_patients, new PatientsSyncPhaseRunnable()),
SYNC_OBSERVATIONS(R.string.syncing_observations, new ObservationsSyncPhaseRunnable()),
SYNC_ORDERS(R.string.syncing_orders, new OrdersSyncPhaseRunnable()),
SYNC_FORMS(R.string.syncing_forms, new FormsSyncPhaseRunnable());
@StringRes
public final int message;
public final SyncPhaseRunnable runnable;
SyncPhase(int message, SyncPhaseRunnable runnable) {
this.message = message;
this.runnable = runnable;
}
}
public enum SyncOption {
/**
* If this key is present with boolean value true, then the starting
* and ending times of the entire sync operation will be recorded (as a
* way of recording whether a full sync has ever successfully completed).
*/
FULL_SYNC
}
public SyncAdapter(Context context, boolean autoInitialize) {
super(context, autoInitialize);
mContentResolver = context.getContentResolver();
}
public SyncAdapter(Context context, boolean autoInitialize, boolean allowParallelSyncs) {
super(context, autoInitialize, allowParallelSyncs);
mContentResolver = context.getContentResolver();
}
@Override public void onSyncCanceled() {
mIsSyncCanceled = true;
LOG.i("Detecting a sync cancellation, canceling sync soon.");
}
/** Not thread-safe but, by default, this will never be called multiple times in parallel. */
@Override public void onPerformSync(
Account account,
Bundle extras,
String authority,
ContentProviderClient provider,
SyncResult syncResult) {
// Broadcast that sync is starting.
Intent syncStartedIntent =
new Intent(getContext(), SyncManager.SyncStatusBroadcastReceiver.class);
syncStartedIntent.putExtra(SyncManager.SYNC_STATUS, SyncManager.STARTED);
getContext().sendBroadcast(syncStartedIntent);
Intent syncFailedIntent =
new Intent(getContext(), SyncManager.SyncStatusBroadcastReceiver.class);
syncFailedIntent.putExtra(SyncManager.SYNC_STATUS, SyncManager.FAILED);
Intent syncCanceledIntent =
new Intent(getContext(), SyncManager.SyncStatusBroadcastReceiver.class);
syncCanceledIntent.putExtra(SyncManager.SYNC_STATUS, SyncManager.CANCELED);
// If we can't access the Buendia API, short-circuit. Before this check was added, sync
// would occasionally hang indefinitely when wifi is unavailable. As a side effect of this
// change, however, any user-requested sync will instantly fail until the HealthMonitor has
// made a determination that the server is definitely accessible.
if (App.getInstance().getHealthMonitor().isApiUnavailable()) {
LOG.e("Abort sync: Buendia API is unavailable.");
getContext().sendBroadcast(syncFailedIntent);
return;
}
try {
checkCancellation("before work started");
} catch (CancellationException e) {
getContext().sendBroadcast(syncCanceledIntent);
return;
}
// Decide which phases to do. If FULL_SYNC is set or no phases
// are specified, do them all.
Set<SyncPhase> phases = new HashSet<>();
for (SyncPhase phase : SyncPhase.values()) {
if (extras.getBoolean(phase.name())) {
phases.add(phase);
}
}
boolean fullSync = phases.isEmpty() || extras.getBoolean(SyncOption.FULL_SYNC.name());
if (fullSync) {
Collections.addAll(phases, SyncPhase.values());
}
LOG.i("Requested phases are: %s", phases);
reportProgress(0, R.string.sync_in_progress);
BuendiaProvider buendiaProvider =
(BuendiaProvider) (provider.getLocalContentProvider());
SQLiteDatabaseTransactionHelper dbTransactionHelper =
buendiaProvider.getDbTransactionHelper();
LOG.i("Setting savepoint %s", SYNC_SAVEPOINT_NAME);
dbTransactionHelper.startNamedTransaction(SYNC_SAVEPOINT_NAME);
TimingLogger timings = new TimingLogger(LOG.tag, "onPerformSync");
try {
if (fullSync) {
Instant syncStartTime = Instant.now();
LOG.i("Recording full sync start time: " + syncStartTime);
storeFullSyncStartTime(provider, syncStartTime);
}
float progressIncrement = 100.0f/phases.size();
int completedPhases = 0;
for (SyncPhase phase : SyncPhase.values()) {
if (!phases.contains(phase)) {
continue;
}
checkCancellation("before " + phase);
LOG.i("--- Begin %s ---", phase);
reportProgress((int) (completedPhases * progressIncrement), phase.message);
phase.runnable.sync(mContentResolver, syncResult, provider);
timings.addSplit(phase.name() + " phase completed");
completedPhases++;
}
reportProgress(100, R.string.completing_sync);
if (fullSync) {
Instant syncEndTime = Instant.now();
LOG.i("Recording full sync end time: " + syncEndTime);
storeFullSyncEndTime(provider, syncEndTime);
}
} catch (CancellationException e) {
rollbackSavepoint(dbTransactionHelper);
// Reset canceled state so that it doesn't interfere with next sync.
LOG.i(e, "Sync canceled");
getContext().sendBroadcast(syncCanceledIntent);
return;
} catch (OperationApplicationException e) {
rollbackSavepoint(dbTransactionHelper);
LOG.e(e, "Error updating database during sync");
syncResult.databaseError = true;
getContext().sendBroadcast(syncFailedIntent);
return;
} catch (Throwable e) {
rollbackSavepoint(dbTransactionHelper);
LOG.e(e, "Error during sync");
syncResult.stats.numIoExceptions++;
getContext().sendBroadcast(syncFailedIntent);
return;
} finally {
LOG.i("Releasing savepoint %s", SYNC_SAVEPOINT_NAME);
dbTransactionHelper.releaseNamedTransaction(SYNC_SAVEPOINT_NAME);
dbTransactionHelper.close();
}
timings.dumpToLog();
// Fire a broadcast indicating that sync has completed.
Intent syncCompletedIntent =
new Intent(getContext(), SyncManager.SyncStatusBroadcastReceiver.class);
syncCompletedIntent.putExtra(SyncManager.SYNC_STATUS, SyncManager.COMPLETED);
getContext().sendBroadcast(syncCompletedIntent);
}
/**
* Enforces sync cancellation, throwing a {@link CancellationException} if the sync has been
* canceled. It is the responsibility of the caller to perform any actual cancellation
* procedures.
*/
private synchronized void checkCancellation(String when) throws CancellationException {
if (mIsSyncCanceled) {
mIsSyncCanceled = false;
throw new CancellationException("Sync cancelled " + when);
}
}
private void reportProgress(int progress, @StringRes int message) {
String label = getContext().getResources().getString(message);
Intent syncProgressIntent =
new Intent(getContext(), SyncManager.SyncStatusBroadcastReceiver.class);
syncProgressIntent.putExtra(SyncManager.SYNC_PROGRESS, progress);
syncProgressIntent.putExtra(SyncManager.SYNC_STATUS, SyncManager.IN_PROGRESS);
syncProgressIntent.putExtra(SyncManager.SYNC_PROGRESS_LABEL, label);
getContext().sendBroadcast(syncProgressIntent);
}
private void storeFullSyncStartTime(ContentProviderClient provider, Instant syncStartTime)
throws RemoteException {
ContentValues cv = new ContentValues();
cv.put(Misc.FULL_SYNC_START_MILLIS, syncStartTime.getMillis());
provider.insert(Misc.CONTENT_URI, cv);
}
private void storeFullSyncEndTime(ContentProviderClient provider, Instant syncEndTime)
throws RemoteException {
ContentValues cv = new ContentValues();
cv.put(Misc.FULL_SYNC_END_MILLIS, syncEndTime.getMillis());
provider.insert(Misc.CONTENT_URI, cv);
}
private void rollbackSavepoint(SQLiteDatabaseTransactionHelper dbTransactionHelper) {
LOG.i("Rolling back savepoint %s", SYNC_SAVEPOINT_NAME);
dbTransactionHelper.rollbackNamedTransaction(SYNC_SAVEPOINT_NAME);
}
/** Returns the server timestamp corresponding to the last observation sync. */
@Nullable
public static String getLastSyncToken(ContentProviderClient provider, Contracts.Table table)
throws RemoteException {
try(Cursor c = provider.query(
SyncTokens.CONTENT_URI.buildUpon().appendPath(table.name).build(),
new String[] {SyncTokens.SYNC_TOKEN}, null, null, null)) {
// Make the linter happy, there's no way that the cursor can be null without throwing
// an exception.
assert c != null;
if (c.moveToNext()) {
// Whether c.getString is null or not is implementation-defined, so we explicitly
// check for nullness.
if (c.isNull(0)) {
return null;
}
return c.getString(0);
} else {
return null;
}
}
}
public static void storeSyncToken(
ContentProviderClient provider, Contracts.Table table, String syncToken)
throws RemoteException {
ContentValues cv = new ContentValues();
cv.put(SyncTokens.TABLE_NAME, table.name);
cv.put(SyncTokens.SYNC_TOKEN, syncToken);
provider.insert(SyncTokens.CONTENT_URI, cv);
}
}