/* * 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 localEntries; /** * Syncable remote journal entries (fetch from server). */ protected List remoteEntries; /** * sync-able resources in the local collection, as enumerated by {@link #prepareLocal()} */ protected Map 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 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 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()); } } }