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

460 lines
20 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.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.SyncResult;
import android.os.Build;
import android.os.Bundle;
import android.text.TextUtils;
import com.squareup.okhttp.HttpUrl;
import com.squareup.okhttp.RequestBody;
import org.slf4j.Logger;
import java.io.IOException;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import at.bitfire.dav4android.DavResource;
import at.bitfire.dav4android.exception.ConflictException;
import at.bitfire.dav4android.exception.DavException;
import at.bitfire.dav4android.exception.HttpException;
import at.bitfire.dav4android.exception.UnauthorizedException;
import at.bitfire.dav4android.exception.PreconditionFailedException;
import at.bitfire.dav4android.exception.ServiceUnavailableException;
import at.bitfire.dav4android.property.GetCTag;
import at.bitfire.dav4android.property.GetETag;
import at.bitfire.davdroid.Constants;
import at.bitfire.davdroid.HttpClient;
import at.bitfire.davdroid.R;
import at.bitfire.davdroid.log.ExternalFileLogger;
import at.bitfire.davdroid.resource.LocalCollection;
import at.bitfire.davdroid.resource.LocalResource;
import at.bitfire.davdroid.ui.DebugInfoActivity;
import at.bitfire.davdroid.ui.settings.AccountActivity;
import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.vcard4android.ContactsStorageException;
abstract public class SyncManager {
protected final int SYNC_PHASE_PREPARE = 0,
SYNC_PHASE_QUERY_CAPABILITIES = 1,
SYNC_PHASE_PROCESS_LOCALLY_DELETED = 2,
SYNC_PHASE_PREPARE_DIRTY = 3,
SYNC_PHASE_UPLOAD_DIRTY = 4,
SYNC_PHASE_CHECK_SYNC_STATE = 5,
SYNC_PHASE_LIST_LOCAL = 6,
SYNC_PHASE_LIST_REMOTE = 7,
SYNC_PHASE_COMPARE_LOCAL_REMOTE = 8,
SYNC_PHASE_DOWNLOAD_REMOTE = 9,
SYNC_PHASE_SAVE_SYNC_STATE = 10;
protected final NotificationManager notificationManager;
protected final int notificationId;
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 Logger log;
protected final HttpClient httpClient;
protected HttpUrl collectionURL;
protected DavResource davCollection;
/** remote CTag at the time of {@link #listRemote()} */
protected String remoteCTag = null;
/** sync-able resources in the local collection, as enumerated by {@link #listLocal()} */
protected Map<String, LocalResource> localResources;
/** sync-able resources in the remote collection, as enumerated by {@link #listRemote()} */
protected Map<String, DavResource> remoteResources;
/** resources which have changed on the server, as determined by {@link #compareLocalRemote()} */
protected Set<DavResource> toDownload;
public SyncManager(int notificationId, Context context, Account account, Bundle extras, String authority, SyncResult syncResult) {
this.context = context;
this.account = account;
this.extras = extras;
this.authority = authority;
this.syncResult = syncResult;
// get account settings and log to file (if requested)
settings = new AccountSettings(context, account);
try {
if (settings.logToExternalFile())
log = new ExternalFileLogger(context, "davdroid-SyncManager-" + account.name + "-" + authority + ".txt", settings.logVerbose());
} catch(IOException e) {
log.error("Couldn't log to external file", e);
}
if (log == null)
log = Constants.log;
// create HttpClient with given logger
httpClient = new HttpClient(log, context, settings.username(), settings.password(), settings.preemptiveAuth());
// dismiss previous error notifications
notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.cancel(account.name, this.notificationId = notificationId);
}
protected abstract String getSyncErrorTitle();
@TargetApi(21)
public void performSync() {
int syncPhase = SYNC_PHASE_PREPARE;
try {
log.info("Preparing synchronization");
prepare();
if (Thread.interrupted())
return;
syncPhase = SYNC_PHASE_QUERY_CAPABILITIES;
log.info("Querying capabilities");
queryCapabilities();
syncPhase = SYNC_PHASE_PROCESS_LOCALLY_DELETED;
log.info("Processing locally deleted entries");
processLocallyDeleted();
if (Thread.interrupted())
return;
syncPhase = SYNC_PHASE_PREPARE_DIRTY;
log.info("Locally preparing dirty entries");
prepareDirty();
syncPhase = SYNC_PHASE_UPLOAD_DIRTY;
log.info("Uploading dirty entries");
uploadDirty();
syncPhase = SYNC_PHASE_CHECK_SYNC_STATE;
log.info("Checking sync state");
if (checkSyncState()) {
syncPhase = SYNC_PHASE_LIST_LOCAL;
log.info("Listing local entries");
listLocal();
if (Thread.interrupted())
return;
syncPhase = SYNC_PHASE_LIST_REMOTE;
log.info("Listing remote entries");
listRemote();
if (Thread.interrupted())
return;
syncPhase = SYNC_PHASE_COMPARE_LOCAL_REMOTE;
log.info("Comparing local/remote entries");
compareLocalRemote();
syncPhase = SYNC_PHASE_DOWNLOAD_REMOTE;
log.info("Downloading remote entries");
downloadRemote();
syncPhase = SYNC_PHASE_SAVE_SYNC_STATE;
log.info("Saving sync state");
saveSyncState();
} else
log.info("Remote collection didn't change, skipping remote sync");
} catch (IOException|ServiceUnavailableException e) {
log.error("I/O exception during sync, trying again later", e);
syncResult.stats.numIoExceptions++;
if (e instanceof ServiceUnavailableException) {
Date retryAfter = ((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 e) {
final int messageString;
if (e instanceof UnauthorizedException) {
log.error("Not authorized anymore", e);
messageString = R.string.sync_error_unauthorized;
syncResult.stats.numAuthExceptions++;
} else if (e instanceof HttpException || e instanceof DavException) {
log.error("HTTP/DAV Exception during sync", e);
messageString = R.string.sync_error_http_dav;
syncResult.stats.numParseExceptions++;
} else if (e instanceof CalendarStorageException || e instanceof ContactsStorageException) {
log.error("Couldn't access local storage", e);
messageString = R.string.sync_error_local_storage;
syncResult.databaseError = true;
} else {
log.error("Unknown sync error", e);
messageString = R.string.sync_error;
syncResult.stats.numParseExceptions++;
}
final Intent detailsIntent;
if (e instanceof UnauthorizedException) {
detailsIntent = new Intent(context, AccountActivity.class);
detailsIntent.putExtra(AccountActivity.EXTRA_ACCOUNT, account);
} else {
detailsIntent = new Intent(context, DebugInfoActivity.class);
detailsIntent.putExtra(DebugInfoActivity.KEY_EXCEPTION, e);
detailsIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account);
detailsIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority);
detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase);
}
Notification.Builder builder = new Notification.Builder(context);
Notification notification;
builder .setSmallIcon(R.drawable.ic_launcher)
.setContentTitle(getSyncErrorTitle())
.setContentIntent(PendingIntent.getActivity(context, notificationId, detailsIntent, PendingIntent.FLAG_UPDATE_CURRENT));
if (Build.VERSION.SDK_INT >= 20)
builder.setLocalOnly(true);
try {
String[] phases = context.getResources().getStringArray(R.array.sync_error_phases);
String message = context.getString(messageString, phases[syncPhase]);
builder.setContentText(message);
} catch (IndexOutOfBoundsException ex) {
// should never happen
}
if (Build.VERSION.SDK_INT >= 16) {
if (Build.VERSION.SDK_INT >= 21)
builder.setCategory(Notification.CATEGORY_ERROR);
notification = builder.build();
} else {
notification = builder.getNotification();
}
notificationManager.notify(account.name, notificationId, notification);
} finally {
if (log instanceof ExternalFileLogger)
try {
((ExternalFileLogger)log).close();
} catch (IOException e) {
Constants.log.error("Couldn't close external log file", e);
}
}
}
abstract protected void prepare() throws ContactsStorageException;
abstract protected void queryCapabilities() throws IOException, HttpException, DavException, CalendarStorageException, ContactsStorageException;
/**
* Process locally deleted entries (DELETE them on the server as well).
* Checks Thread.interrupted() before each request to allow quick sync cancellation.
*/
protected void processLocallyDeleted() throws CalendarStorageException, ContactsStorageException {
// 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();
for (LocalResource local : localList) {
if (Thread.interrupted())
return;
final String fileName = local.getFileName();
if (!TextUtils.isEmpty(fileName)) {
log.info(fileName + " has been deleted locally -> deleting from server");
try {
new DavResource(log, httpClient, collectionURL.newBuilder().addPathSegment(fileName).build())
.delete(local.getETag());
} catch (IOException|HttpException e) {
log.warn("Couldn't delete " + fileName + " from server; ignoring (may be downloaded again)");
}
} else
log.info("Removing local record #" + local.getId() + " which has been deleted locally and was never uploaded");
local.delete();
syncResult.stats.numDeletes++;
}
}
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
for (LocalResource local : localCollection.getWithoutFileName()) {
String uuid = UUID.randomUUID().toString();
log.info("Found local record #" + local.getId() + " without file name; assigning file name/UID based on " + uuid);
local.updateFileNameAndUID(uuid);
}
}
abstract protected RequestBody prepareUpload(LocalResource resource) throws IOException, CalendarStorageException, ContactsStorageException;
/**
* Uploads dirty records to the server, using a PUT request for each record.
* Checks Thread.interrupted() before each request to allow quick sync cancellation.
*/
protected void uploadDirty() throws IOException, HttpException, CalendarStorageException, ContactsStorageException {
// upload dirty contacts
for (LocalResource local : localCollection.getDirty()) {
if (Thread.interrupted())
return;
final String fileName = local.getFileName();
DavResource remote = new DavResource(log, httpClient, collectionURL.newBuilder().addPathSegment(fileName).build());
// generate entity to upload (VCard, iCal, whatever)
RequestBody body = prepareUpload(local);
try {
if (local.getETag() == null) {
log.info("Uploading new record " + fileName);
remote.put(body, null, true);
} else {
log.info("Uploading locally modified record " + fileName);
remote.put(body, local.getETag(), false);
}
} catch (ConflictException|PreconditionFailedException e) {
// we can't interact with the user to resolve the conflict, so we treat 409 like 412
log.info("Resource has been modified on the server before upload, ignoring", e);
}
String eTag = null;
GetETag newETag = (GetETag) remote.properties.get(GetETag.NAME);
if (newETag != null) {
eTag = newETag.eTag;
log.debug("Received new ETag=" + eTag + " after uploading");
} else
log.debug("Didn't receive new ETag after uploading, setting to null");
local.clearDirty(eTag);
}
}
/**
* Checks the current sync state (e.g. CTag) and whether synchronization from remote is required.
* @return <ul>
* <li><code>true</code> if the remote collection has changed, i.e. synchronization from remote is required</li>
* <li><code>false</code> if the remote collection hasn't changed</li>
* </ul>
*/
protected boolean checkSyncState() throws CalendarStorageException, ContactsStorageException {
// check CTag (ignore on manual sync)
GetCTag getCTag = (GetCTag)davCollection.properties.get(GetCTag.NAME);
if (getCTag != null)
remoteCTag = getCTag.cTag;
String localCTag = null;
if (extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL))
log.info("Manual sync, ignoring CTag");
else
localCTag = localCollection.getCTag();
if (remoteCTag != null && remoteCTag.equals(localCTag)) {
log.info("Remote collection didn't change (CTag=" + remoteCTag + "), no need to query children");
return false;
} else
return true;
}
/**
* Lists all local resources which should be taken into account for synchronization into {@link #localResources}.
*/
protected void listLocal() throws CalendarStorageException, ContactsStorageException {
// 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) {
log.debug("Found local resource: " + resource.getFileName());
localResources.put(resource.getFileName(), resource);
}
}
/**
* Lists all members of the remote collection which should be taken into account for synchronization into {@link #remoteResources}.
*/
abstract protected void listRemote() throws IOException, HttpException, DavException;
/**
* Compares {@link #localResources} and {@link #remoteResources} by file name and ETag:
* <ul>
* <li>Local resources which are not available in the remote collection (anymore) will be removed.</li>
* <li>Resources whose remote ETag has changed will be added into {@link #toDownload}</li>
* </ul>
*/
protected void compareLocalRemote() throws IOException, HttpException, DavException, CalendarStorageException, ContactsStorageException {
/* check which contacts
1. are not present anymore remotely -> delete immediately on local side
2. updated remotely -> add to downloadNames
3. added remotely -> add to downloadNames
*/
toDownload = new HashSet<>();
for (String localName : localResources.keySet()) {
DavResource remote = remoteResources.get(localName);
if (remote == null) {
log.info(localName + " is not on server anymore, deleting");
localResources.get(localName).delete();
syncResult.stats.numDeletes++;
} else {
// contact is still on server, check whether it has been updated remotely
GetETag getETag = (GetETag) remote.properties.get(GetETag.NAME);
if (getETag == null || getETag.eTag == null)
throw new DavException("Server didn't provide ETag");
String localETag = localResources.get(localName).getETag(),
remoteETag = getETag.eTag;
if (remoteETag.equals(localETag))
syncResult.stats.numSkippedEntries++;
else {
log.info(localName + " has been changed on server (current ETag=" + remoteETag + ", last known ETag=" + localETag + ")");
toDownload.add(remote);
}
// remote entry has been seen, remove from list
remoteResources.remove(localName);
}
}
// add all unseen (= remotely added) remote contacts
if (!remoteResources.isEmpty()) {
log.info("New VCards have been found on the server: " + TextUtils.join(", ", remoteResources.keySet()));
toDownload.addAll(remoteResources.values());
}
}
/**
* Downloads the remote resources in {@link #toDownload} and stores them locally.
* Must check Thread.interrupted() periodically to allow quick sync cancellation.
*/
abstract protected void downloadRemote() throws IOException, HttpException, DavException, ContactsStorageException, CalendarStorageException;
protected void saveSyncState() throws CalendarStorageException, ContactsStorageException {
/* Save sync state (CTag). It doesn't matter if it has changed during the sync process
(for instance, because another client has uploaded changes), because this will simply
cause all remote entries to be listed at the next sync. */
log.info("Saving CTag=" + remoteCTag);
localCollection.setCTag(remoteCTag);
}
}