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

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/*
* 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());
}
}
}