mirror of https://github.com/etesync/android
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.
362 lines
14 KiB
362 lines
14 KiB
10 years ago
|
/*******************************************************************************
|
||
|
* Copyright (c) 2014 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.resource;
|
||
|
|
||
|
import java.util.ArrayList;
|
||
|
|
||
|
import lombok.Cleanup;
|
||
|
import android.accounts.Account;
|
||
|
import android.content.ContentProviderClient;
|
||
|
import android.content.ContentProviderOperation;
|
||
|
import android.content.ContentProviderOperation.Builder;
|
||
|
import android.content.ContentUris;
|
||
|
import android.content.ContentValues;
|
||
|
import android.content.OperationApplicationException;
|
||
|
import android.database.Cursor;
|
||
|
import android.net.Uri;
|
||
|
import android.os.RemoteException;
|
||
|
import android.provider.CalendarContract;
|
||
|
import android.util.Log;
|
||
|
|
||
|
/**
|
||
|
* Represents a locally-stored synchronizable collection (for instance, the
|
||
|
* address book or a calendar). Manages a CTag that stores the last known
|
||
|
* remote CTag (the remote CTag changes whenever something in the remote collection changes).
|
||
|
*
|
||
|
* @param <T> Subtype of Resource that can be stored in the collection
|
||
|
*/
|
||
|
public abstract class LocalCollection<T extends Resource> {
|
||
|
private static final String TAG = "davdroid.LocalCollection";
|
||
|
|
||
|
protected Account account;
|
||
|
protected ContentProviderClient providerClient;
|
||
|
protected ArrayList<ContentProviderOperation> pendingOperations = new ArrayList<ContentProviderOperation>();
|
||
|
|
||
|
|
||
|
// database fields
|
||
|
|
||
|
/** base Uri of the collection's entries (for instance, Events.CONTENT_URI);
|
||
|
* apply syncAdapterURI() before returning a value */
|
||
|
abstract protected Uri entriesURI();
|
||
|
|
||
|
/** column name of the type of the account the entry belongs to */
|
||
|
abstract protected String entryColumnAccountType();
|
||
|
/** column name of the name of the account the entry belongs to */
|
||
|
abstract protected String entryColumnAccountName();
|
||
|
|
||
|
/** column name of the collection ID the entry belongs to */
|
||
|
abstract protected String entryColumnParentID();
|
||
|
/** column name of an entry's ID */
|
||
|
abstract protected String entryColumnID();
|
||
|
/** column name of an entry's file name on the WebDAV server */
|
||
|
abstract protected String entryColumnRemoteName();
|
||
|
/** column name of an entry's last ETag on the WebDAV server; null if entry hasn't been uploaded yet */
|
||
|
abstract protected String entryColumnETag();
|
||
|
|
||
|
/** column name of an entry's "dirty" flag (managed by content provider) */
|
||
|
abstract protected String entryColumnDirty();
|
||
|
/** column name of an entry's "deleted" flag (managed by content provider) */
|
||
|
abstract protected String entryColumnDeleted();
|
||
|
|
||
|
/** column name of an entry's UID */
|
||
|
abstract protected String entryColumnUID();
|
||
|
|
||
|
|
||
|
LocalCollection(Account account, ContentProviderClient providerClient) {
|
||
|
this.account = account;
|
||
|
this.providerClient = providerClient;
|
||
|
}
|
||
|
|
||
|
|
||
|
// collection operations
|
||
|
|
||
|
/** gets the ID if the collection (for instance, ID of the Android calendar) */
|
||
|
abstract public long getId();
|
||
|
/** gets the CTag of the collection */
|
||
|
abstract public String getCTag() throws LocalStorageException;
|
||
|
/** sets the CTag of the collection */
|
||
|
abstract public void setCTag(String cTag) throws LocalStorageException;
|
||
|
|
||
|
|
||
|
// content provider (= database) querying
|
||
|
|
||
|
/**
|
||
|
* Finds new resources (resources which haven't been uploaded yet).
|
||
|
* New resources are 1) dirty, and 2) don't have an ETag yet.
|
||
|
*
|
||
|
* @return IDs of new resources
|
||
|
* @throws LocalStorageException when the content provider couldn't be queried
|
||
|
*/
|
||
|
public long[] findNew() throws LocalStorageException {
|
||
|
String where = entryColumnDirty() + "=1 AND " + entryColumnETag() + " IS NULL";
|
||
|
if (entryColumnParentID() != null)
|
||
|
where += " AND " + entryColumnParentID() + "=" + String.valueOf(getId());
|
||
|
try {
|
||
|
@Cleanup Cursor cursor = providerClient.query(entriesURI(),
|
||
|
new String[] { entryColumnID() },
|
||
|
where, null, null);
|
||
|
if (cursor == null)
|
||
|
throw new LocalStorageException("Couldn't query new records");
|
||
|
|
||
|
long[] fresh = new long[cursor.getCount()];
|
||
|
for (int idx = 0; cursor.moveToNext(); idx++) {
|
||
|
long id = cursor.getLong(0);
|
||
|
|
||
|
// new record: generate UID + remote file name so that we can upload
|
||
|
T resource = findById(id, false);
|
||
|
resource.initialize();
|
||
|
// write generated UID + remote file name into database
|
||
|
ContentValues values = new ContentValues(2);
|
||
|
values.put(entryColumnUID(), resource.getUid());
|
||
|
values.put(entryColumnRemoteName(), resource.getName());
|
||
|
providerClient.update(ContentUris.withAppendedId(entriesURI(), id), values, null, null);
|
||
|
|
||
|
fresh[idx] = id;
|
||
|
}
|
||
|
return fresh;
|
||
|
} catch(RemoteException ex) {
|
||
|
throw new LocalStorageException(ex);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Finds updated resources (resources which have already been uploaded, but have changed locally).
|
||
|
* Updated resources are 1) dirty, and 2) already have an ETag.
|
||
|
*
|
||
|
* @return IDs of updated resources
|
||
|
* @throws LocalStorageException when the content provider couldn't be queried
|
||
|
*/
|
||
|
public long[] findUpdated() throws LocalStorageException {
|
||
|
String where = entryColumnDirty() + "=1 AND " + entryColumnETag() + " IS NOT NULL";
|
||
|
if (entryColumnParentID() != null)
|
||
|
where += " AND " + entryColumnParentID() + "=" + String.valueOf(getId());
|
||
|
try {
|
||
|
@Cleanup Cursor cursor = providerClient.query(entriesURI(),
|
||
|
new String[] { entryColumnID(), entryColumnRemoteName(), entryColumnETag() },
|
||
|
where, null, null);
|
||
|
if (cursor == null)
|
||
|
throw new LocalStorageException("Couldn't query updated records");
|
||
|
|
||
|
long[] updated = new long[cursor.getCount()];
|
||
|
for (int idx = 0; cursor.moveToNext(); idx++)
|
||
|
updated[idx] = cursor.getLong(0);
|
||
|
return updated;
|
||
|
} catch(RemoteException ex) {
|
||
|
throw new LocalStorageException(ex);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Finds deleted resources (resources which have been marked for deletion).
|
||
|
* Deleted resources have the "deleted" flag set.
|
||
|
*
|
||
|
* @return IDs of deleted resources
|
||
|
* @throws LocalStorageException when the content provider couldn't be queried
|
||
|
*/
|
||
|
public long[] findDeleted() throws LocalStorageException {
|
||
|
String where = entryColumnDeleted() + "=1";
|
||
|
if (entryColumnParentID() != null)
|
||
|
where += " AND " + entryColumnParentID() + "=" + String.valueOf(getId());
|
||
|
try {
|
||
|
@Cleanup Cursor cursor = providerClient.query(entriesURI(),
|
||
|
new String[] { entryColumnID(), entryColumnRemoteName(), entryColumnETag() },
|
||
|
where, null, null);
|
||
|
if (cursor == null)
|
||
|
throw new LocalStorageException("Couldn't query dirty records");
|
||
|
|
||
|
long deleted[] = new long[cursor.getCount()];
|
||
|
for (int idx = 0; cursor.moveToNext(); idx++)
|
||
|
deleted[idx] = cursor.getLong(0);
|
||
|
return deleted;
|
||
|
} catch(RemoteException ex) {
|
||
|
throw new LocalStorageException(ex);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Finds a specific resource by ID.
|
||
|
* @param localID ID of the resource
|
||
|
* @param populate true: populates all data fields (for instance, contact or event details);
|
||
|
* false: only remote file name and ETag are populated
|
||
|
* @return resource with either ID/remote file/name/ETag or all fields populated
|
||
|
* @throws RecordNotFoundException when the resource couldn't be found
|
||
|
* @throws LocalStorageException when the content provider couldn't be queried
|
||
|
*/
|
||
|
public T findById(long localID, boolean populate) throws LocalStorageException {
|
||
|
try {
|
||
|
@Cleanup Cursor cursor = providerClient.query(ContentUris.withAppendedId(entriesURI(), localID),
|
||
|
new String[] { entryColumnRemoteName(), entryColumnETag() }, null, null, null);
|
||
|
if (cursor != null && cursor.moveToNext()) {
|
||
|
T resource = newResource(localID, cursor.getString(0), cursor.getString(1));
|
||
|
if (populate)
|
||
|
populate(resource);
|
||
|
return resource;
|
||
|
} else
|
||
|
throw new RecordNotFoundException();
|
||
|
} catch(RemoteException ex) {
|
||
|
throw new LocalStorageException(ex);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Finds a specific resource by remote file name.
|
||
|
* @param localID remote file name of the resource
|
||
|
* @param populate true: populates all data fields (for instance, contact or event details);
|
||
|
* false: only remote file name and ETag are populated
|
||
|
* @return resource with either ID/remote file/name/ETag or all fields populated
|
||
|
* @throws RecordNotFoundException when the resource couldn't be found
|
||
|
* @throws LocalStorageException when the content provider couldn't be queried
|
||
|
*/
|
||
|
public T findByRemoteName(String remoteName, boolean populate) throws LocalStorageException {
|
||
|
try {
|
||
|
@Cleanup Cursor cursor = providerClient.query(entriesURI(),
|
||
|
new String[] { entryColumnID(), entryColumnRemoteName(), entryColumnETag() },
|
||
|
entryColumnRemoteName() + "=?", new String[] { remoteName }, null);
|
||
|
if (cursor != null && cursor.moveToNext()) {
|
||
|
T resource = newResource(cursor.getLong(0), cursor.getString(1), cursor.getString(2));
|
||
|
if (populate)
|
||
|
populate(resource);
|
||
|
return resource;
|
||
|
} else
|
||
|
throw new RecordNotFoundException();
|
||
|
} catch(RemoteException ex) {
|
||
|
throw new LocalStorageException(ex);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/** populates all data fields from the content provider */
|
||
|
public abstract void populate(Resource record) throws LocalStorageException;
|
||
|
|
||
|
|
||
|
// create/update/delete
|
||
|
|
||
|
/**
|
||
|
* Creates a new resource object in memory. No content provider operations involved.
|
||
|
* @param localID the ID of the resource
|
||
|
* @param resourceName the (remote) file name of the resource
|
||
|
* @param ETag of the resource
|
||
|
* @return the new resource object */
|
||
|
abstract public T newResource(long localID, String resourceName, String eTag);
|
||
|
|
||
|
/** Enqueues adding the resource (including all data) to the local collection. Requires commit(). */
|
||
|
public void add(Resource resource) {
|
||
|
int idx = pendingOperations.size();
|
||
|
pendingOperations.add(
|
||
|
buildEntry(ContentProviderOperation.newInsert(entriesURI()), resource)
|
||
|
.withYieldAllowed(true)
|
||
|
.build());
|
||
|
|
||
|
addDataRows(resource, -1, idx);
|
||
|
}
|
||
|
|
||
|
/** Enqueues updating an existing resource in the local collection. The resource will be found by
|
||
|
* the remote file name and all data will be updated. Requires commit(). */
|
||
|
public void updateByRemoteName(Resource remoteResource) throws LocalStorageException {
|
||
|
T localResource = findByRemoteName(remoteResource.getName(), false);
|
||
|
pendingOperations.add(
|
||
|
buildEntry(ContentProviderOperation.newUpdate(ContentUris.withAppendedId(entriesURI(), localResource.getLocalID())), remoteResource)
|
||
|
.withValue(entryColumnETag(), remoteResource.getETag())
|
||
|
.withYieldAllowed(true)
|
||
|
.build());
|
||
|
|
||
|
removeDataRows(localResource);
|
||
|
addDataRows(remoteResource, localResource.getLocalID(), -1);
|
||
|
}
|
||
|
|
||
|
/** Enqueues deleting a resource from the local collection. Requires commit(). */
|
||
|
public void delete(Resource resource) {
|
||
|
pendingOperations.add(ContentProviderOperation
|
||
|
.newDelete(ContentUris.withAppendedId(entriesURI(), resource.getLocalID()))
|
||
|
.withYieldAllowed(true)
|
||
|
.build());
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Enqueues deleting all resources except the give ones from the local collection. Requires commit().
|
||
|
* @param remoteResources resources with these remote file names will be kept
|
||
|
*/
|
||
|
public abstract void deleteAllExceptRemoteNames(Resource[] remoteResources);
|
||
|
|
||
|
/** Updates the locally-known ETag of a resource. */
|
||
|
public void updateETag(Resource res, String eTag) throws LocalStorageException {
|
||
|
Log.d(TAG, "Setting ETag of local resource " + res + " to " + eTag);
|
||
|
|
||
|
ContentValues values = new ContentValues(1);
|
||
|
values.put(entryColumnETag(), eTag);
|
||
|
try {
|
||
|
providerClient.update(ContentUris.withAppendedId(entriesURI(), res.getLocalID()), values, null, new String[] {});
|
||
|
} catch (RemoteException e) {
|
||
|
throw new LocalStorageException(e);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/** Enqueues removing the dirty flag from a locally-stored resource. Requires commit(). */
|
||
|
public void clearDirty(Resource resource) {
|
||
|
pendingOperations.add(ContentProviderOperation
|
||
|
.newUpdate(ContentUris.withAppendedId(entriesURI(), resource.getLocalID()))
|
||
|
.withValue(entryColumnDirty(), 0)
|
||
|
.build());
|
||
|
}
|
||
|
|
||
|
/** Commits enqueued operations to the content provider (for batch operations). */
|
||
|
public void commit() throws LocalStorageException {
|
||
|
if (!pendingOperations.isEmpty())
|
||
|
try {
|
||
|
Log.d(TAG, "Committing " + pendingOperations.size() + " operations");
|
||
|
providerClient.applyBatch(pendingOperations);
|
||
|
pendingOperations.clear();
|
||
|
} catch (RemoteException ex) {
|
||
|
throw new LocalStorageException(ex);
|
||
|
} catch(OperationApplicationException ex) {
|
||
|
throw new LocalStorageException(ex);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
|
||
|
// helpers
|
||
|
|
||
|
protected void queueOperation(Builder builder) {
|
||
|
if (builder != null)
|
||
|
pendingOperations.add(builder.build());
|
||
|
}
|
||
|
|
||
|
/** Appends account type, name and CALLER_IS_SYNCADAPTER to an Uri. */
|
||
|
protected Uri syncAdapterURI(Uri baseURI) {
|
||
|
return baseURI.buildUpon()
|
||
|
.appendQueryParameter(entryColumnAccountType(), account.type)
|
||
|
.appendQueryParameter(entryColumnAccountName(), account.name)
|
||
|
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
|
||
|
.build();
|
||
|
}
|
||
|
|
||
|
protected Builder newDataInsertBuilder(Uri dataUri, String refFieldName, long raw_ref_id, Integer backrefIdx) {
|
||
|
Builder builder = ContentProviderOperation.newInsert(syncAdapterURI(dataUri));
|
||
|
if (backrefIdx != -1)
|
||
|
return builder.withValueBackReference(refFieldName, backrefIdx);
|
||
|
else
|
||
|
return builder.withValue(refFieldName, raw_ref_id);
|
||
|
}
|
||
|
|
||
|
|
||
|
// content builders
|
||
|
|
||
|
/**
|
||
|
* Builds the main entry (for instance, a ContactsContract.RawContacts row) from a resource.
|
||
|
* The entry is built for insertion to the location identified by entriesURI().
|
||
|
*
|
||
|
* @param builder Builder to be extended by all resource data that can be stored without extra data rows.
|
||
|
*/
|
||
|
protected abstract Builder buildEntry(Builder builder, Resource resource);
|
||
|
|
||
|
/** Enqueues adding extra data rows of the resource to the local collection. */
|
||
|
protected abstract void addDataRows(Resource resource, long localID, int backrefIdx);
|
||
|
|
||
|
/** Enqueues removing all extra data rows of the resource from the local collection. */
|
||
|
protected abstract void removeDataRows(Resource resource);
|
||
|
}
|