You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
etesync-android/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncManager.java

448 lines
18 KiB

/*
* Copyright © 2013 2015 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.syncadapter;
import android.accounts.Account;
import android.annotation.TargetApi;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.SyncResult;
import android.net.Uri;
import android.os.Bundle;
import android.support.v4.app.NotificationManagerCompat;
import android.support.v7.app.NotificationCompat;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.logging.Level;
import at.bitfire.davdroid.AccountSettings;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.GsonHelper;
import at.bitfire.davdroid.HttpClient;
import at.bitfire.davdroid.InvalidAccountException;
import at.bitfire.davdroid.R;
import at.bitfire.davdroid.journalmanager.Exceptions;
import at.bitfire.davdroid.journalmanager.JournalEntryManager;
import at.bitfire.davdroid.resource.LocalCollection;
import at.bitfire.davdroid.resource.LocalResource;
import at.bitfire.davdroid.ui.AccountSettingsActivity;
import at.bitfire.davdroid.ui.DebugInfoActivity;
import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.ical4android.InvalidCalendarException;
import at.bitfire.vcard4android.ContactsStorageException;
import lombok.Getter;
import okhttp3.OkHttpClient;
abstract public class SyncManager {
protected final String SYNC_PHASE_PREPARE = "sync_phase_prepare",
SYNC_PHASE_QUERY_CAPABILITIES = "sync_phase_query_capabilities",
SYNC_PHASE_PREPARE_LOCAL = "sync_phase_prepare_local",
SYNC_PHASE_CREATE_LOCAL_ENTRIES = "sync_phase_create_local_entries",
SYNC_PHASE_FETCH_ENTRIES = "sync_phase_fetch_entries",
SYNC_PHASE_APPLY_REMOTE_ENTRIES = "sync_phase_apply_remote_entries",
SYNC_PHASE_APPLY_LOCAL_ENTRIES = "sync_phase_apply_local_entries",
SYNC_PHASE_PUSH_ENTRIES = "sync_phase_push_entries",
SYNC_PHASE_POST_PROCESSING = "sync_phase_post_processing",
SYNC_PHASE_SAVE_SYNC_TAG = "sync_phase_save_sync_tag";
protected final NotificationManagerCompat notificationManager;
protected final String uniqueCollectionId;
protected final Context context;
protected final Account account;
protected final Bundle extras;
protected final String authority;
protected final SyncResult syncResult;
protected final AccountSettings settings;
protected LocalCollection localCollection;
protected OkHttpClient httpClient;
protected JournalEntryManager journal;
/**
* remote CTag (uuid of the last entry on the server). We update it when we fetch/push and save when everything works.
*/
protected String remoteCTag = null;
/**
* Syncable local journal entries.
*/
protected List<JournalEntryManager.Entry> localEntries;
/**
* Syncable remote journal entries (fetch from server).
*/
protected List<JournalEntryManager.Entry> remoteEntries;
/**
* sync-able resources in the local collection, as enumerated by {@link #prepareLocal()}
*/
protected Map<String, LocalResource> localResources;
public SyncManager(Context context, Account account, AccountSettings settings, Bundle extras, String authority, SyncResult syncResult, String uniqueCollectionId) throws InvalidAccountException {
this.context = context;
this.account = account;
this.settings = settings;
this.extras = extras;
this.authority = authority;
this.syncResult = syncResult;
// create HttpClient with given logger
httpClient = HttpClient.create(context, account);
// dismiss previous error notifications
this.uniqueCollectionId = uniqueCollectionId;
notificationManager = NotificationManagerCompat.from(context);
notificationManager.cancel(uniqueCollectionId, notificationId());
}
protected abstract int notificationId();
protected abstract String getSyncErrorTitle();
@TargetApi(21)
public void performSync() {
String syncPhase = SYNC_PHASE_PREPARE;
try {
App.log.info("Sync phase: " + syncPhase);
prepare();
if (Thread.interrupted())
return;
syncPhase = SYNC_PHASE_QUERY_CAPABILITIES;
App.log.info("Sync phase: " + syncPhase);
queryCapabilities();
if (Thread.interrupted())
return;
syncPhase = SYNC_PHASE_PREPARE_LOCAL;
App.log.info("Sync phase: " + syncPhase);
prepareLocal();
/* Create journal entries out of local changes. */
if (Thread.interrupted())
return;
syncPhase = SYNC_PHASE_CREATE_LOCAL_ENTRIES;
App.log.info("Sync phase: " + syncPhase);
createLocalEntries();
if (Thread.interrupted())
return;
syncPhase = SYNC_PHASE_FETCH_ENTRIES;
App.log.info("Sync phase: " + syncPhase);
fetchEntries();
if (Thread.interrupted())
return;
syncPhase = SYNC_PHASE_APPLY_REMOTE_ENTRIES;
App.log.info("Sync phase: " + syncPhase);
applyRemoteEntries();
if (Thread.interrupted())
return;
syncPhase = SYNC_PHASE_APPLY_LOCAL_ENTRIES;
App.log.info("Sync phase: " + syncPhase);
applyLocalEntries();
if (Thread.interrupted())
return;
syncPhase = SYNC_PHASE_PUSH_ENTRIES;
App.log.info("Sync phase: " + syncPhase);
pushEntries();
/* Cleanup and finalize changes */
if (Thread.interrupted())
return;
syncPhase = SYNC_PHASE_POST_PROCESSING;
App.log.info("Sync phase: " + syncPhase);
postProcess();
syncPhase = SYNC_PHASE_SAVE_SYNC_TAG;
App.log.info("Sync phase: " + syncPhase);
saveSyncTag();
} catch (IOException e) {
App.log.log(Level.WARNING, "I/O exception during sync, trying again later", e);
syncResult.stats.numIoExceptions++;
} catch (Exceptions.ServiceUnavailableException e) {
Date retryAfter = null; // ((Exceptions.ServiceUnavailableException) e).retryAfter;
if (retryAfter != null) {
// how many seconds to wait? getTime() returns ms, so divide by 1000
// syncResult.delayUntil = (retryAfter.getTime() - new Date().getTime()) / 1000;
}
} catch (Exception | OutOfMemoryError e) {
final int messageString;
if (e instanceof Exceptions.UnauthorizedException) {
App.log.log(Level.SEVERE, "Not authorized anymore", e);
messageString = R.string.sync_error_unauthorized;
syncResult.stats.numAuthExceptions++;
} else if (e instanceof Exceptions.HttpException) {
App.log.log(Level.SEVERE, "HTTP Exception during sync", e);
messageString = R.string.sync_error_http_dav;
syncResult.stats.numParseExceptions++;
} else if (e instanceof CalendarStorageException || e instanceof ContactsStorageException) {
App.log.log(Level.SEVERE, "Couldn't access local storage", e);
messageString = R.string.sync_error_local_storage;
syncResult.databaseError = true;
} else if (e instanceof Exceptions.IntegrityException) {
App.log.log(Level.SEVERE, "Integrity error", e);
// FIXME: Make a proper error message
messageString = R.string.sync_error;
syncResult.stats.numParseExceptions++;
} else {
App.log.log(Level.SEVERE, "Unknown sync error", e);
messageString = R.string.sync_error;
syncResult.stats.numParseExceptions++;
}
final Intent detailsIntent;
if (e instanceof Exceptions.UnauthorizedException) {
detailsIntent = new Intent(context, AccountSettingsActivity.class);
detailsIntent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account);
} else {
detailsIntent = new Intent(context, DebugInfoActivity.class);
detailsIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e);
detailsIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account);
detailsIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority);
detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase);
}
// to make the PendingIntent unique
detailsIntent.setData(Uri.parse("uri://" + getClass().getName() + "/" + uniqueCollectionId));
NotificationCompat.Builder builder = new NotificationCompat.Builder(context);
builder.setSmallIcon(R.drawable.ic_error_light)
.setLargeIcon(App.getLauncherBitmap(context))
.setContentTitle(getSyncErrorTitle())
.setContentIntent(PendingIntent.getActivity(context, 0, detailsIntent, PendingIntent.FLAG_CANCEL_CURRENT))
.setCategory(NotificationCompat.CATEGORY_ERROR);
String message = context.getString(messageString, syncPhase);
builder.setContentText(message);
notificationManager.notify(uniqueCollectionId, notificationId(), builder.build());
}
}
abstract protected void prepare() throws ContactsStorageException, CalendarStorageException;
abstract protected void processSyncEntry(SyncEntry cEntry) throws IOException, ContactsStorageException, CalendarStorageException, InvalidCalendarException;
abstract protected void applyLocalEntries() throws IOException, ContactsStorageException, CalendarStorageException, Exceptions.HttpException;
protected void queryCapabilities() throws IOException, CalendarStorageException, ContactsStorageException {
}
protected void fetchEntries() throws Exceptions.HttpException, ContactsStorageException, CalendarStorageException, Exceptions.IntegrityException {
remoteEntries = journal.getEntries(settings.password(), remoteCTag);
if (!remoteEntries.isEmpty()) {
remoteCTag = remoteEntries.get(remoteEntries.size() - 1).getUuid();
}
}
protected void applyRemoteEntries() throws IOException, ContactsStorageException, CalendarStorageException, InvalidCalendarException {
// Process new vcards from server
for (JournalEntryManager.Entry entry : remoteEntries) {
if (Thread.interrupted())
return;
App.log.info("Processing " + entry.toString());
SyncEntry cEntry = SyncEntry.fromJournalEntry(settings.password(), entry);
App.log.info("Processing resource for journal entry " + entry.getUuid());
processSyncEntry(cEntry);
}
}
protected void pushEntries() throws Exceptions.HttpException, IOException, ContactsStorageException, CalendarStorageException {
// upload dirty contacts
// FIXME: Deal with failure
if (!localEntries.isEmpty()) {
journal.putEntries(localEntries, remoteCTag);
for (LocalResource local : localCollection.getDirty()) {
App.log.info("Added/changed resource with UUID: " + local.getUuid());
local.clearDirty(local.getUuid());
}
for (LocalResource local : localCollection.getDeleted()) {
local.delete();
}
remoteCTag = localEntries.get(localEntries.size() - 1).getUuid();
}
}
protected void createLocalEntries() throws CalendarStorageException, ContactsStorageException, IOException {
localEntries = new LinkedList<>();
// Not saving, just creating a fake one until we load it from a local db
JournalEntryManager.Entry previousEntry = (remoteCTag != null) ? JournalEntryManager.Entry.getFakeWithUid(remoteCTag) : null;
for (LocalResource local : processLocallyDeleted()) {
SyncEntry entry = new SyncEntry(local.getContent(), SyncEntry.Actions.DELETE);
JournalEntryManager.Entry tmp = new JournalEntryManager.Entry();
tmp.update(settings.password(), entry.toJson(), previousEntry);
previousEntry = tmp;
localEntries.add(previousEntry);
}
try {
for (LocalResource local : localCollection.getDirty()) {
SyncEntry.Actions action;
if (local.isLocalOnly()) {
action = SyncEntry.Actions.ADD;
} else {
action = SyncEntry.Actions.CHANGE;
}
SyncEntry entry = new SyncEntry(local.getContent(), action);
JournalEntryManager.Entry tmp = new JournalEntryManager.Entry();
tmp.update(settings.password(), entry.toJson(), previousEntry);
previousEntry = tmp;
localEntries.add(previousEntry);
}
} catch (FileNotFoundException e) {
// FIXME: Do something
e.printStackTrace();
}
}
/**
* Lists all local resources which should be taken into account for synchronization into {@link #localResources}.
*/
protected void prepareLocal() throws CalendarStorageException, ContactsStorageException {
prepareDirty();
// fetch list of local contacts and build hash table to index file name
LocalResource[] localList = localCollection.getAll();
localResources = new HashMap<>(localList.length);
for (LocalResource resource : localList) {
App.log.fine("Found local resource: " + resource.getUuid());
localResources.put(resource.getUuid(), resource);
}
remoteCTag = localCollection.getCTag();
}
/**
* Delete unpublished locally deleted, and return the rest.
* Checks Thread.interrupted() before each request to allow quick sync cancellation.
*/
protected List<LocalResource> processLocallyDeleted() throws CalendarStorageException, ContactsStorageException {
// FIXME: This needs refactoring and fixing, it's just not true.
// Remove locally deleted entries from server (if they have a name, i.e. if they were uploaded before),
// but only if they don't have changed on the server. Then finally remove them from the local address book.
LocalResource[] localList = localCollection.getDeleted();
List<LocalResource> ret = new ArrayList<>(localList.length);
for (LocalResource local : localList) {
if (Thread.interrupted())
return ret;
if (!local.isLocalOnly()) {
App.log.info(local.getUuid() + " has been deleted locally -> deleting from server");
ret.add(local);
} else {
App.log.info("Removing local record #" + local.getId() + " which has been deleted locally and was never uploaded");
local.delete();
}
syncResult.stats.numDeletes++;
}
return ret;
}
protected void prepareDirty() throws CalendarStorageException, ContactsStorageException {
// assign file names and UIDs to new contacts so that we can use the file name as an index
App.log.info("Looking for contacts/groups without file name");
for (LocalResource local : localCollection.getWithoutFileName()) {
String uuid = UUID.randomUUID().toString();
App.log.fine("Found local record #" + local.getId() + " without file name; assigning file name/UID based on " + uuid);
local.updateFileNameAndUID(uuid);
}
}
/**
* For post-processing of entries, for instance assigning groups.
*/
protected void postProcess() throws CalendarStorageException, ContactsStorageException {
}
protected void saveSyncTag() throws CalendarStorageException, ContactsStorageException {
App.log.info("Saving CTag=" + remoteCTag);
localCollection.setCTag(remoteCTag);
}
static class SyncEntry {
@Getter
private String content;
@Getter
private Actions action;
enum Actions {
ADD("ADD"),
CHANGE("CHANGE"),
DELETE("DELETE");
private final String text;
Actions(final String text) {
this.text = text;
}
@Override
public String toString() {
return text;
}
}
@SuppressWarnings("unused")
private SyncEntry() {
}
protected SyncEntry(String content, Actions action) {
this.content = content;
this.action = action;
}
boolean isAction(Actions action) {
return this.action.equals(action);
}
static SyncEntry fromJournalEntry(String keyBase64, JournalEntryManager.Entry entry) {
return GsonHelper.gson.fromJson(entry.getContent(keyBase64), SyncEntry.class);
}
String toJson() {
return GsonHelper.gson.toJson(this, this.getClass());
}
}
}