1
0
mirror of https://github.com/etesync/android synced 2024-11-22 16:08:13 +00:00

Basic support for VCard4-style groups

* rewritten contact group support to support VCard3 CATEGORIES and VCard4-style KIND/MEMBER groups
* new account setting: contact group method (VCard3/VCard4/Apple "VCard4-as-VCard3")
* keep unknown properties when saving/generating VCards
This commit is contained in:
Ricki Hirner 2016-06-08 21:44:31 +02:00
parent 91234a688f
commit b30733c64b
15 changed files with 741 additions and 200 deletions

View File

@ -45,6 +45,7 @@ import at.bitfire.davdroid.resource.LocalTaskList;
import at.bitfire.ical4android.CalendarStorageException; import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.ical4android.TaskProvider; import at.bitfire.ical4android.TaskProvider;
import at.bitfire.vcard4android.ContactsStorageException; import at.bitfire.vcard4android.ContactsStorageException;
import at.bitfire.vcard4android.GroupMethod;
import lombok.Cleanup; import lombok.Cleanup;
import okhttp3.HttpUrl; import okhttp3.HttpUrl;
@ -72,6 +73,14 @@ public class AccountSettings {
"0" false */ "0" false */
private final static String KEY_MANAGE_CALENDAR_COLORS = "manage_calendar_colors"; private final static String KEY_MANAGE_CALENDAR_COLORS = "manage_calendar_colors";
/** Contact group method:
automatic VCard4 if server supports VCard 4, VCard3 otherwise (default value)
VCard3 adds a contact's groups to its CATEGORIES / interprets a contact's CATEGORIES as groups
VCard4 uses groups as defined in VCard 4 (KIND/MEMBER properties)
Apple uses Apple-proprietary X-ADDRESSBOOK-KIND/-MEMBER properties
*/
private final static String KEY_CONTACT_GROUP_METHOD = "contact_group_method";
public final static long SYNC_INTERVAL_MANUALLY = -1; public final static long SYNC_INTERVAL_MANUALLY = -1;
final Context context; final Context context;
@ -184,6 +193,7 @@ public class AccountSettings {
// CalDAV settings // CalDAV settings
@Nullable
public Integer getTimeRangePastDays() { public Integer getTimeRangePastDays() {
String strDays = accountManager.getUserData(account, KEY_TIME_RANGE_PAST_DAYS); String strDays = accountManager.getUserData(account, KEY_TIME_RANGE_PAST_DAYS);
if (strDays != null) { if (strDays != null) {
@ -193,7 +203,7 @@ public class AccountSettings {
return DEFAULT_TIME_RANGE_PAST_DAYS; return DEFAULT_TIME_RANGE_PAST_DAYS;
} }
public void setTimeRangePastDays(Integer days) { public void setTimeRangePastDays(@Nullable Integer days) {
accountManager.setUserData(account, KEY_TIME_RANGE_PAST_DAYS, String.valueOf(days == null ? -1 : days)); accountManager.setUserData(account, KEY_TIME_RANGE_PAST_DAYS, String.valueOf(days == null ? -1 : days));
} }
@ -206,6 +216,22 @@ public class AccountSettings {
} }
// CardDAV settings
@NonNull
public GroupMethod getGroupMethod() {
final String name = accountManager.getUserData(account, KEY_CONTACT_GROUP_METHOD);
return name != null ?
GroupMethod.valueOf(name) :
GroupMethod.AUTOMATIC;
}
public void setGroupMethod(@NonNull GroupMethod method) {
final String name = GroupMethod.AUTOMATIC.equals(method) ? null : method.name();
accountManager.setUserData(account, KEY_CONTACT_GROUP_METHOD, name);
}
// update from previous account settings // update from previous account settings
private void update(int fromVersion) { private void update(int fromVersion) {

View File

@ -0,0 +1,22 @@
/*
* Copyright © 2013 2016 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.model;
import android.provider.ContactsContract.RawContacts;
public class UnknownProperties {
public static final String CONTENT_ITEM_TYPE = "x.davdroid/unknown-properties";
public static final String
MIMETYPE = RawContacts.Data.MIMETYPE,
RAW_CONTACT_ID = RawContacts.Data.RAW_CONTACT_ID,
UNKNOWN_PROPERTIES = RawContacts.Data.DATA1;
}

View File

@ -12,22 +12,22 @@ import android.content.ContentProviderClient;
import android.content.ContentUris; import android.content.ContentUris;
import android.content.ContentValues; import android.content.ContentValues;
import android.database.Cursor; import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.os.Parcel; import android.os.Parcel;
import android.os.RemoteException; import android.os.RemoteException;
import android.provider.ContactsContract;
import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
import android.provider.ContactsContract.Data;
import android.provider.ContactsContract.Groups; import android.provider.ContactsContract.Groups;
import android.provider.ContactsContract.RawContacts; import android.provider.ContactsContract.RawContacts;
import android.support.annotation.NonNull;
import java.io.FileNotFoundException;
import java.util.Collections;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import at.bitfire.davdroid.App;
import at.bitfire.vcard4android.AndroidAddressBook; import at.bitfire.vcard4android.AndroidAddressBook;
import at.bitfire.vcard4android.AndroidContact; import at.bitfire.vcard4android.AndroidContact;
import at.bitfire.vcard4android.AndroidGroupFactory; import at.bitfire.vcard4android.AndroidGroup;
import at.bitfire.vcard4android.ContactsStorageException; import at.bitfire.vcard4android.ContactsStorageException;
import lombok.Cleanup; import lombok.Cleanup;
@ -40,122 +40,128 @@ public class LocalAddressBook extends AndroidAddressBook implements LocalCollect
private final Bundle syncState = new Bundle(); private final Bundle syncState = new Bundle();
/**
* Whether contact groups (LocalGroup resources) are included in query results for
* {@link #getAll()}, {@link #getDeleted()}, {@link #getDirty()} and
* {@link #getWithoutFileName()}.
*/
public boolean includeGroups = true;
public LocalAddressBook(Account account, ContentProviderClient provider) { public LocalAddressBook(Account account, ContentProviderClient provider) {
super(account, provider, AndroidGroupFactory.INSTANCE, LocalContact.Factory.INSTANCE); super(account, provider, LocalGroup.Factory.INSTANCE, LocalContact.Factory.INSTANCE);
} }
public LocalContact findContactByUID(String uid) throws ContactsStorageException, FileNotFoundException {
LocalContact[] contacts = (LocalContact[])queryContacts(LocalContact.COLUMN_UID + "=?", new String[] { uid });
if (contacts.length == 0)
throw new FileNotFoundException();
return contacts[0];
}
/**
* Returns an array of local contacts, excluding those which have been modified locally (and not uploaded yet).
*/
@Override @Override
public LocalContact[] getAll() throws ContactsStorageException { public LocalResource[] getAll() throws ContactsStorageException {
return (LocalContact[])queryContacts(null, null); List<LocalResource> all = new LinkedList<>();
Collections.addAll(all, (LocalResource[])queryContacts(null, null));
if (includeGroups)
Collections.addAll(all, (LocalResource[])queryGroups(null, null));
return all.toArray(new LocalResource[all.size()]);
} }
/** /**
* Returns an array of local contacts which have been deleted locally. (DELETED != 0). * Returns an array of local contacts/groups which have been deleted locally. (DELETED != 0).
*/ */
@Override @Override
public LocalContact[] getDeleted() throws ContactsStorageException { public LocalResource[] getDeleted() throws ContactsStorageException {
return (LocalContact[])queryContacts(RawContacts.DELETED + "!=0", null); List<LocalResource> deleted = new LinkedList<>();
Collections.addAll(deleted, getDeletedContacts());
if (includeGroups)
Collections.addAll(deleted, getDeletedGroups());
return deleted.toArray(new LocalResource[deleted.size()]);
} }
/** /**
* Returns an array of local contacts which have been changed locally (DIRTY != 0). * Returns an array of local contacts/groups which have been changed locally (DIRTY != 0).
*/ */
@Override @Override
public LocalContact[] getDirty() throws ContactsStorageException { public LocalResource[] getDirty() throws ContactsStorageException {
return (LocalContact[])queryContacts(RawContacts.DIRTY + "!=0", null); List<LocalResource> dirty = new LinkedList<>();
Collections.addAll(dirty, getDirtyContacts());
if (includeGroups)
Collections.addAll(dirty, getDirtyGroups());
return dirty.toArray(new LocalResource[dirty.size()]);
} }
/** /**
* Returns an array of local contacts which don't have a file name yet. * Returns an array of local contacts which don't have a file name yet.
*/ */
@Override @Override
public LocalContact[] getWithoutFileName() throws ContactsStorageException { public LocalResource[] getWithoutFileName() throws ContactsStorageException {
return (LocalContact[])queryContacts(AndroidContact.COLUMN_FILENAME + " IS NULL", null); List<LocalResource> nameless = new LinkedList<>();
Collections.addAll(nameless, (LocalContact[])queryContacts(AndroidContact.COLUMN_FILENAME + " IS NULL", null));
if (includeGroups)
Collections.addAll(nameless, (LocalGroup[])queryGroups(AndroidGroup.COLUMN_FILENAME + " IS NULL", null));
return nameless.toArray(new LocalResource[nameless.size()]);
} }
public void deleteAll() throws ContactsStorageException { public void deleteAll() throws ContactsStorageException {
try { try {
provider.delete(syncAdapterURI(RawContacts.CONTENT_URI), null, null); provider.delete(syncAdapterURI(RawContacts.CONTENT_URI), null, null);
} catch (RemoteException e) { provider.delete(syncAdapterURI(Groups.CONTENT_URI), null, null);
throw new ContactsStorageException("Couldn't delete all local contacts", e); } catch(RemoteException e) {
throw new ContactsStorageException("Couldn't delete all local contacts and groups", e);
} }
} }
// GROUPS public LocalContact[] getDeletedContacts() throws ContactsStorageException {
return (LocalContact[])queryContacts(RawContacts.DELETED + "!= 0", null);
}
public LocalContact[] getDirtyContacts() throws ContactsStorageException {
return (LocalContact[])queryContacts(RawContacts.DIRTY + "!= 0", null);
}
public LocalGroup[] getDeletedGroups() throws ContactsStorageException {
return (LocalGroup[])queryGroups(Groups.DELETED + "!= 0", null);
}
public LocalGroup[] getDirtyGroups() throws ContactsStorageException {
return (LocalGroup[])queryGroups(Groups.DIRTY + "!= 0", null);
}
/** /**
* Finds the first group with the given title. * Finds the first group with the given title. If there is no group with this
* @param displayName title of the group to look for * title, a new group is created.
* @return group with given title, or null if none * @param title title of the group to look for
* @return id of the group with given title
* @throws ContactsStorageException on contact provider errors
*/ */
@SuppressWarnings("Recycle") public long findOrCreateGroup(@NonNull String title) throws ContactsStorageException {
public LocalGroup findGroupByTitle(String displayName) throws ContactsStorageException {
try { try {
@Cleanup Cursor cursor = provider.query(syncAdapterURI(Groups.CONTENT_URI), @Cleanup Cursor cursor = provider.query(syncAdapterURI(Groups.CONTENT_URI),
new String[] { Groups._ID }, new String[] { Groups._ID },
ContactsContract.Groups.TITLE + "=?", new String[] { displayName }, null); Groups.TITLE + "=?", new String[] { title },
null);
if (cursor != null && cursor.moveToNext()) if (cursor != null && cursor.moveToNext())
return new LocalGroup(this, cursor.getLong(0)); return cursor.getLong(0);
} catch (RemoteException e) {
ContentValues values = new ContentValues();
values.put(Groups.TITLE, title);
Uri uri = provider.insert(syncAdapterURI(Groups.CONTENT_URI), values);
return ContentUris.parseId(uri);
} catch(RemoteException e) {
throw new ContactsStorageException("Couldn't find local contact group", e); throw new ContactsStorageException("Couldn't find local contact group", e);
} }
return null;
} }
@SuppressWarnings("Recycle") public void removeEmptyGroups() throws ContactsStorageException {
public LocalGroup[] getDeletedGroups() throws ContactsStorageException { // find groups without members
List<LocalGroup> groups = new LinkedList<>(); /** should be done using {@link Groups.SUMMARY_COUNT}, but it's not implemented in Android yet */
try { for (LocalGroup group : (LocalGroup[])queryGroups(null, null))
@Cleanup Cursor cursor = provider.query(syncAdapterURI(Groups.CONTENT_URI), if (group.getMembers().length == 0)
new String[] { Groups._ID }, group.delete();
Groups.DELETED + "!=0", null, null);
while (cursor != null && cursor.moveToNext())
groups.add(new LocalGroup(this, cursor.getLong(0)));
} catch (RemoteException e) {
throw new ContactsStorageException("Couldn't query deleted groups", e);
}
return groups.toArray(new LocalGroup[groups.size()]);
}
@SuppressWarnings("Recycle")
public LocalGroup[] getDirtyGroups() throws ContactsStorageException {
List<LocalGroup> groups = new LinkedList<>();
try {
@Cleanup Cursor cursor = provider.query(syncAdapterURI(Groups.CONTENT_URI),
new String[] { Groups._ID },
Groups.DIRTY + "!=0", null, null);
while (cursor != null && cursor.moveToNext())
groups.add(new LocalGroup(this, cursor.getLong(0)));
} catch (RemoteException e) {
throw new ContactsStorageException("Couldn't query dirty groups", e);
}
return groups.toArray(new LocalGroup[groups.size()]);
}
@SuppressWarnings("Recycle")
public void markMembersDirty(long groupId) throws ContactsStorageException {
ContentValues dirty = new ContentValues(1);
dirty.put(RawContacts.DIRTY, 1);
try {
// query all GroupMemberships of this groupId, mark every corresponding raw contact as DIRTY
@Cleanup Cursor cursor = provider.query(syncAdapterURI(Data.CONTENT_URI),
new String[] { GroupMembership.RAW_CONTACT_ID },
Data.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?",
new String[] { GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(groupId) }, null);
while (cursor != null && cursor.moveToNext()) {
long id = cursor.getLong(0);
App.log.fine("Marking raw contact #" + id + " as dirty");
provider.update(syncAdapterURI(ContentUris.withAppendedId(RawContacts.CONTENT_URI, id)), dirty, null, null);
}
} catch (RemoteException e) {
throw new ContactsStorageException("Couldn't query dirty groups", e);
}
} }

View File

@ -13,16 +13,20 @@ import android.content.ContentValues;
import android.os.RemoteException; import android.os.RemoteException;
import android.provider.ContactsContract; import android.provider.ContactsContract;
import android.provider.ContactsContract.CommonDataKinds.GroupMembership; import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
import android.provider.ContactsContract.RawContacts.Data;
import android.support.annotation.NonNull;
import java.io.FileNotFoundException; import java.io.FileNotFoundException;
import java.util.logging.Level; import java.util.HashSet;
import java.util.Set;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.BuildConfig; import at.bitfire.davdroid.BuildConfig;
import at.bitfire.davdroid.model.UnknownProperties;
import at.bitfire.vcard4android.AndroidAddressBook; import at.bitfire.vcard4android.AndroidAddressBook;
import at.bitfire.vcard4android.AndroidContact; import at.bitfire.vcard4android.AndroidContact;
import at.bitfire.vcard4android.AndroidContactFactory; import at.bitfire.vcard4android.AndroidContactFactory;
import at.bitfire.vcard4android.BatchOperation; import at.bitfire.vcard4android.BatchOperation;
import at.bitfire.vcard4android.CachedGroupMembership;
import at.bitfire.vcard4android.Contact; import at.bitfire.vcard4android.Contact;
import at.bitfire.vcard4android.ContactsStorageException; import at.bitfire.vcard4android.ContactsStorageException;
import ezvcard.Ezvcard; import ezvcard.Ezvcard;
@ -32,6 +36,11 @@ public class LocalContact extends AndroidContact implements LocalResource {
Contact.productID = "+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " vcard4android ez-vcard/" + Ezvcard.VERSION; Contact.productID = "+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " vcard4android ez-vcard/" + Ezvcard.VERSION;
} }
protected final Set<Long>
cachedGroupMemberships = new HashSet<>(),
groupMemberships = new HashSet<>();
protected LocalContact(AndroidAddressBook addressBook, long id, String fileName, String eTag) { protected LocalContact(AndroidAddressBook addressBook, long id, String fileName, String eTag) {
super(addressBook, id, fileName, eTag); super(addressBook, id, fileName, eTag);
} }
@ -69,53 +78,94 @@ public class LocalContact extends AndroidContact implements LocalResource {
} }
// group support
@Override @Override
protected void populateGroupMembership(ContentValues row) { protected void populateData(String mimeType, ContentValues row) {
if (row.containsKey(GroupMembership.GROUP_ROW_ID)) { switch (mimeType) {
long groupId = row.getAsLong(GroupMembership.GROUP_ROW_ID); case CachedGroupMembership.CONTENT_ITEM_TYPE:
cachedGroupMemberships.add(row.getAsLong(CachedGroupMembership.GROUP_ID));
// fetch group break;
LocalGroup group = new LocalGroup(addressBook, groupId); case GroupMembership.CONTENT_ITEM_TYPE:
try { groupMemberships.add(row.getAsLong(GroupMembership.GROUP_ROW_ID));
Contact groupInfo = group.getContact(); break;
case UnknownProperties.CONTENT_ITEM_TYPE:
// add to CATEGORIES contact.unknownProperties = row.getAsString(UnknownProperties.UNKNOWN_PROPERTIES);
contact.categories.add(groupInfo.displayName); break;
} catch (FileNotFoundException|ContactsStorageException e) {
App.log.log(Level.WARNING, "Couldn't find assigned group #" + groupId + ", ignoring membership", e);
}
} }
} }
@Override @Override
protected void insertGroupMemberships(BatchOperation batch) throws ContactsStorageException { protected void insertDataRows(BatchOperation batch) throws ContactsStorageException {
for (String category : contact.categories) { super.insertDataRows(batch);
// Is there already a category with this display name?
LocalGroup group = ((LocalAddressBook)addressBook).findGroupByTitle(category);
if (group == null) { if (contact.unknownProperties != null) {
// no, we have to create the group before inserting the membership ContentProviderOperation.Builder builder = ContentProviderOperation.newInsert(dataSyncURI());
if (id == null)
Contact groupInfo = new Contact(); builder.withValueBackReference(UnknownProperties.RAW_CONTACT_ID, 0);
groupInfo.displayName = category; else
group = new LocalGroup(addressBook, groupInfo); builder.withValue(UnknownProperties.RAW_CONTACT_ID, id);
group.create(); builder .withValue(UnknownProperties.MIMETYPE, UnknownProperties.CONTENT_ITEM_TYPE)
} .withValue(UnknownProperties.UNKNOWN_PROPERTIES, contact.unknownProperties);
batch.enqueue(builder.build());
Long groupId = group.getId();
if (groupId != null) {
ContentProviderOperation.Builder builder = ContentProviderOperation.newInsert(dataSyncURI());
if (id == null)
builder.withValueBackReference(GroupMembership.RAW_CONTACT_ID, 0);
else
builder.withValue(GroupMembership.RAW_CONTACT_ID, id);
builder .withValue(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE)
.withValue(GroupMembership.GROUP_ROW_ID, groupId);
batch.enqueue(builder.build());
}
} }
}
public void addToGroup(BatchOperation batch, long groupID) {
assertID();
batch.enqueue(ContentProviderOperation
.newInsert(dataSyncURI())
.withValue(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE)
.withValue(GroupMembership.RAW_CONTACT_ID, id)
.withValue(GroupMembership.GROUP_ROW_ID, groupID)
.build()
);
batch.enqueue(ContentProviderOperation
.newInsert(dataSyncURI())
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
.withValue(CachedGroupMembership.RAW_CONTACT_ID, id)
.withValue(CachedGroupMembership.GROUP_ID, groupID)
.build()
);
}
public void removeGroupMemberships(BatchOperation batch) {
assertID();
batch.enqueue(ContentProviderOperation
.newDelete(dataSyncURI())
.withSelection(
Data.RAW_CONTACT_ID + "=? AND " + Data.MIMETYPE + " IN (?,?)",
new String[] { String.valueOf(id), GroupMembership.CONTENT_ITEM_TYPE, CachedGroupMembership.CONTENT_ITEM_TYPE }
)
.build()
);
}
/**
* Returns the IDs of all groups the contact was member of (cached memberships).
* Cached memberships are kept in sync with memberships by DAVdroid and are used to determine
* whether a membership has been deleted/added when a raw contact is dirty.
* @return set of {@link GroupMembership#GROUP_ROW_ID} (may be empty)
* @throws ContactsStorageException on contact provider errors
* @throws FileNotFoundException if the current contact can't be found
*/
@NonNull
public Set<Long> getCachedGroupMemberships() throws ContactsStorageException, FileNotFoundException {
getContact();
return cachedGroupMemberships;
}
/**
* Returns the IDs of all groups the contact is member of.
* @return set of {@link GroupMembership#GROUP_ROW_ID}s (may be empty)
* @throws ContactsStorageException on contact provider errors
* @throws FileNotFoundException if the current contact can't be found
*/
@NonNull
public Set<Long> getGroupMemberships() throws ContactsStorageException, FileNotFoundException {
getContact();
return groupMemberships;
} }
@ -134,6 +184,7 @@ public class LocalContact extends AndroidContact implements LocalResource {
return new LocalContact(addressBook, contact, fileName, eTag); return new LocalContact(addressBook, contact, fileName, eTag);
} }
@Override
public LocalContact[] newArray(int size) { public LocalContact[] newArray(int size) {
return new LocalContact[size]; return new LocalContact[size];
} }

View File

@ -8,28 +8,241 @@
package at.bitfire.davdroid.resource; package at.bitfire.davdroid.resource;
import android.content.ContentProviderOperation;
import android.content.ContentUris;
import android.content.ContentValues; import android.content.ContentValues;
import android.database.Cursor;
import android.os.Parcel;
import android.os.RemoteException;
import android.provider.ContactsContract; import android.provider.ContactsContract;
import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
import android.provider.ContactsContract.Groups;
import android.provider.ContactsContract.RawContacts;
import android.provider.ContactsContract.RawContacts.Data;
import org.apache.commons.lang3.ArrayUtils;
import java.io.FileNotFoundException;
import java.util.LinkedList;
import java.util.List;
import java.util.logging.Level;
import at.bitfire.dav4android.Constants;
import at.bitfire.vcard4android.AndroidAddressBook; import at.bitfire.vcard4android.AndroidAddressBook;
import at.bitfire.vcard4android.AndroidGroup; import at.bitfire.vcard4android.AndroidGroup;
import at.bitfire.vcard4android.AndroidGroupFactory;
import at.bitfire.vcard4android.BatchOperation;
import at.bitfire.vcard4android.CachedGroupMembership;
import at.bitfire.vcard4android.Contact; import at.bitfire.vcard4android.Contact;
import at.bitfire.vcard4android.ContactsStorageException; import at.bitfire.vcard4android.ContactsStorageException;
import lombok.Cleanup;
import lombok.ToString;
public class LocalGroup extends AndroidGroup { @ToString(callSuper=true)
public class LocalGroup extends AndroidGroup implements LocalResource {
/** marshalled list of member UIDs, as sent by server */
public static final String COLUMN_PENDING_MEMBERS = Groups.SYNC3;
public LocalGroup(AndroidAddressBook addressBook, long id) { public LocalGroup(AndroidAddressBook addressBook, long id, String fileName, String eTag) {
super(addressBook, id); super(addressBook, id, fileName, eTag);
} }
public LocalGroup(AndroidAddressBook addressBook, Contact contact) { public LocalGroup(AndroidAddressBook addressBook, Contact contact, String fileName, String eTag) {
super(addressBook, contact); super(addressBook, contact, fileName, eTag);
} }
public void clearDirty() throws ContactsStorageException {
ContentValues values = new ContentValues(1); @Override
values.put(ContactsContract.Groups.DIRTY, 0); public void clearDirty(String eTag) throws ContactsStorageException {
assertID();
ContentValues values = new ContentValues(2);
values.put(Groups.DIRTY, 0);
values.put(COLUMN_ETAG, this.eTag = eTag);
update(values); update(values);
// update cached group memberships
BatchOperation batch = new BatchOperation(addressBook.provider);
// delete cached group memberships
batch.enqueue(ContentProviderOperation
.newDelete(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
.withSelection(
CachedGroupMembership.MIMETYPE + "=? AND " + CachedGroupMembership.GROUP_ID + "=?",
new String[] { CachedGroupMembership.CONTENT_ITEM_TYPE, String.valueOf(id) }
).build()
);
// insert updated cached group memberships
for (long member : getMembers())
batch.enqueue(ContentProviderOperation
.newInsert(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
.withValue(CachedGroupMembership.RAW_CONTACT_ID, member)
.withValue(CachedGroupMembership.GROUP_ID, id)
.build()
);
batch.commit();
}
@Override
public void updateFileNameAndUID(String uid) throws ContactsStorageException {
String newFileName = uid + ".vcf";
ContentValues values = new ContentValues(2);
values.put(COLUMN_FILENAME, newFileName);
values.put(COLUMN_UID, uid);
update(values);
fileName = newFileName;
}
@Override
protected ContentValues contentValues() {
ContentValues values = super.contentValues();
@Cleanup("recycle") Parcel members = Parcel.obtain();
members.writeStringList(contact.members);
values.put(COLUMN_PENDING_MEMBERS, members.marshall());
return values;
}
/**
* Marks all members of the current group as dirty.
*/
public void markMembersDirty() throws ContactsStorageException {
assertID();
BatchOperation batch = new BatchOperation(addressBook.provider);
for (long member : getMembers())
batch.enqueue(ContentProviderOperation
.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(RawContacts.CONTENT_URI, member)))
.withValue(RawContacts.DIRTY, 1)
.build()
);
batch.commit();
}
/**
* Processes all groups with non-null {@link #COLUMN_PENDING_MEMBERS}: the pending memberships
* are (if possible) applied, keeping cached memberships in sync.
* @param addressBook address book to take groups from
* @throws ContactsStorageException on contact provider errors
*/
public static void applyPendingMemberships(LocalAddressBook addressBook) throws ContactsStorageException {
try {
@Cleanup Cursor cursor = addressBook.provider.query(
addressBook.syncAdapterURI(Groups.CONTENT_URI),
new String[] { Groups._ID, COLUMN_PENDING_MEMBERS },
COLUMN_PENDING_MEMBERS + " IS NOT NULL", new String[] {},
null
);
BatchOperation batch = new BatchOperation(addressBook.provider);
while (cursor != null && cursor.moveToNext()) {
long id = cursor.getLong(0);
Constants.log.fine("Assigning members to group " + id);
// delete all memberships and cached memberships for this group
batch.enqueue(ContentProviderOperation
.newDelete(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
.withSelection(
"(" + GroupMembership.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?) OR (" +
CachedGroupMembership.MIMETYPE + "=? AND " + CachedGroupMembership.GROUP_ID + "=?)",
new String[] { GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(id), CachedGroupMembership.CONTENT_ITEM_TYPE, String.valueOf(id) })
.build()
);
// extract list of member UIDs
List<String> members = new LinkedList<>();
byte[] raw = cursor.getBlob(1);
@Cleanup("recycle") Parcel parcel = Parcel.obtain();
parcel.unmarshall(raw, 0, raw.length);
parcel.setDataPosition(0);
parcel.readStringList(members);
// insert memberships
for (String uid : members) {
Constants.log.fine("Assigning member: " + uid);
try {
LocalContact member = addressBook.findContactByUID(uid);
member.addToGroup(batch, id);
} catch(FileNotFoundException e) {
Constants.log.log(Level.WARNING, "Group member not found: " + uid, e);
}
}
// remove pending memberships
batch.enqueue(ContentProviderOperation
.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, id)))
.withValue(COLUMN_PENDING_MEMBERS, null)
.build()
);
batch.commit();
}
} catch(RemoteException e) {
throw new ContactsStorageException("Couldn't get pending memberships", e);
}
}
// helpers
private void assertID() {
if (id == null)
throw new IllegalStateException("Group has not been saved yet");
}
/**
* Lists all members of this group.
* @return list of all members' raw contact IDs
* @throws ContactsStorageException on contact provider errors
*/
protected long[] getMembers() throws ContactsStorageException {
assertID();
List<Long> members = new LinkedList<>();
try {
@Cleanup Cursor cursor = addressBook.provider.query(
addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI),
new String[] { Data.RAW_CONTACT_ID },
GroupMembership.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?",
new String[] { GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(id) },
null
);
while (cursor != null && cursor.moveToNext())
members.add(cursor.getLong(0));
} catch(RemoteException e) {
throw new ContactsStorageException("Couldn't list group members", e);
}
return ArrayUtils.toPrimitive(members.toArray(new Long[members.size()]));
}
// factory
static class Factory extends AndroidGroupFactory {
static final Factory INSTANCE = new Factory();
@Override
public LocalGroup newInstance(AndroidAddressBook addressBook, long id, String fileName, String eTag) {
return new LocalGroup(addressBook, id, fileName, eTag);
}
@Override
public LocalGroup newInstance(AndroidAddressBook addressBook, Contact contact, String fileName, String eTag) {
return new LocalGroup(addressBook, contact, fileName, eTag);
}
@Override
public LocalGroup[] newArray(int size) {
return new LocalGroup[size];
}
} }
} }

View File

@ -55,6 +55,9 @@ import okhttp3.MediaType;
import okhttp3.RequestBody; import okhttp3.RequestBody;
import okhttp3.ResponseBody; import okhttp3.ResponseBody;
/**
* Synchronization manager for CalDAV collections; handles events ({@code VEVENT}).
*/
public class CalendarSyncManager extends SyncManager { public class CalendarSyncManager extends SyncManager {
protected static final int MAX_MULTIGET = 20; protected static final int MAX_MULTIGET = 20;

View File

@ -10,26 +10,33 @@ package at.bitfire.davdroid.syncadapter;
import android.accounts.Account; import android.accounts.Account;
import android.content.ContentProviderClient; import android.content.ContentProviderClient;
import android.content.ContentProviderOperation;
import android.content.ContentUris;
import android.content.ContentValues; import android.content.ContentValues;
import android.content.Context; import android.content.Context;
import android.content.SyncResult; import android.content.SyncResult;
import android.database.Cursor;
import android.os.Bundle; import android.os.Bundle;
import android.os.Environment; import android.os.RemoteException;
import android.provider.ContactsContract; import android.provider.ContactsContract;
import android.provider.ContactsContract.Groups;
import android.support.annotation.NonNull;
import android.text.TextUtils;
import org.apache.commons.codec.Charsets; import org.apache.commons.codec.Charsets;
import org.apache.commons.collections4.SetUtils;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.charset.Charset; import java.nio.charset.Charset;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.logging.Level; import java.util.logging.Level;
import at.bitfire.dav4android.DavAddressBook; import at.bitfire.dav4android.DavAddressBook;
@ -54,8 +61,10 @@ import at.bitfire.davdroid.resource.LocalContact;
import at.bitfire.davdroid.resource.LocalGroup; import at.bitfire.davdroid.resource.LocalGroup;
import at.bitfire.davdroid.resource.LocalResource; import at.bitfire.davdroid.resource.LocalResource;
import at.bitfire.ical4android.CalendarStorageException; import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.vcard4android.BatchOperation;
import at.bitfire.vcard4android.Contact; import at.bitfire.vcard4android.Contact;
import at.bitfire.vcard4android.ContactsStorageException; import at.bitfire.vcard4android.ContactsStorageException;
import at.bitfire.vcard4android.GroupMethod;
import ezvcard.VCardVersion; import ezvcard.VCardVersion;
import ezvcard.util.IOUtils; import ezvcard.util.IOUtils;
import lombok.Cleanup; import lombok.Cleanup;
@ -68,12 +77,49 @@ import okhttp3.RequestBody;
import okhttp3.Response; import okhttp3.Response;
import okhttp3.ResponseBody; import okhttp3.ResponseBody;
/**
* <p>Synchronization manager for CardDAV collections; handles contacts and groups.</p>
*
* <p></p>Group handling differs according to the {@link #groupMethod}. There are two basic methods to
* handle/manage groups:</p>
* <ul>
* <li>VCard3 {@code CATEGORIES}: groups memberships are attached to each contact and represented as
* "category". When a group is dirty or has been deleted, all its members have to be set to
* dirty, too (because they have to be uploaded without the respective category). This
* is done in {@link #prepareDirty()}. Empty groups can be deleted without further processing,
* which is done in {@link #postProcess()} because groups may become empty after downloading
* updated remoted contacts.</li>
* <li>VCard4-style: individual and group contacts (with a list of member UIDs) are
* distinguished. When a local group is dirty, its members don't need to be set to dirty.
* <ol>
* <li>However, when a contact is dirty, it has
* to be checked whether its group memberships have changed. In this case, the respective
* groups have to be set to dirty. For instance, if contact A is in group G and H, and then
* group membership of G is removed, the contact will be set to dirty because of the changed
* {@link android.provider.ContactsContract.CommonDataKinds.GroupMembership}. DAVdroid will
* then have to check whether the group memberships have actually changed, and if so,
* all affected groups have to be set to dirty. To detect changes in group memberships,
* DAVdroid always mirrors all {@link android.provider.ContactsContract.CommonDataKinds.GroupMembership}
* data rows in respective {@link at.bitfire.vcard4android.CachedGroupMembership} rows.
* If the cached group memberships are not the same as the current group member ships, the
* difference set (in our example G, because its in the cached memberships, but not in the
* actual ones) is marked as dirty. This is done in {@link #prepareDirty()}.</li>
* <li>When downloading remote contacts, groups (+ member information) may be received
* by the actual members. Thus, the member lists have to be cached until all VCards
* are received. This is done by caching the member UIDs of each group in
* {@link LocalGroup#COLUMN_PENDING_MEMBERS}. In {@link #postProcess()},
* these "pending memberships" are assigned to the actual contacs and then cleaned up.</li>
* </ol>
* </ul>
*/
public class ContactsSyncManager extends SyncManager { public class ContactsSyncManager extends SyncManager {
protected static final int MAX_MULTIGET = 10; protected static final int MAX_MULTIGET = 10;
final private ContentProviderClient provider; final private ContentProviderClient provider;
final private CollectionInfo remote; final private CollectionInfo remote;
private boolean hasVCard4; private boolean hasVCard4;
private GroupMethod groupMethod;
public ContactsSyncManager(Context context, Account account, AccountSettings settings, Bundle extras, String authority, ContentProviderClient provider, SyncResult result, CollectionInfo remote) throws InvalidAccountException { public ContactsSyncManager(Context context, Account account, AccountSettings settings, Bundle extras, String authority, ContentProviderClient provider, SyncResult result, CollectionInfo remote) throws InvalidAccountException {
@ -107,37 +153,113 @@ public class ContactsSyncManager extends SyncManager {
} }
// set up Contacts Provider Settings // set up Contacts Provider Settings
ContentValues settings = new ContentValues(2); ContentValues values = new ContentValues(2);
settings.put(ContactsContract.Settings.SHOULD_SYNC, 1); values.put(ContactsContract.Settings.SHOULD_SYNC, 1);
settings.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1); values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1);
localAddressBook.updateSettings(settings); localAddressBook.updateSettings(values);
collectionURL = HttpUrl.parse(url); collectionURL = HttpUrl.parse(url);
davCollection = new DavAddressBook(httpClient, collectionURL); davCollection = new DavAddressBook(httpClient, collectionURL);
processChangedGroups();
} }
@Override @Override
protected void queryCapabilities() throws DavException, IOException, HttpException { protected void queryCapabilities() throws DavException, IOException, HttpException {
// prepare remote address book // prepare remote address book
hasVCard4 = false;
davCollection.propfind(0, SupportedAddressData.NAME, GetCTag.NAME); davCollection.propfind(0, SupportedAddressData.NAME, GetCTag.NAME);
SupportedAddressData supportedAddressData = (SupportedAddressData) davCollection.properties.get(SupportedAddressData.NAME); SupportedAddressData supportedAddressData = (SupportedAddressData)davCollection.properties.get(SupportedAddressData.NAME);
if (supportedAddressData != null) hasVCard4 = supportedAddressData != null && supportedAddressData.hasVCard4();
for (MediaType type : supportedAddressData.types)
if ("text/vcard; version=4.0".equalsIgnoreCase(type.toString()))
hasVCard4 = true;
App.log.info("Server advertises VCard/4 support: " + hasVCard4); App.log.info("Server advertises VCard/4 support: " + hasVCard4);
groupMethod = settings.getGroupMethod();
if (GroupMethod.AUTOMATIC.equals(groupMethod))
groupMethod = hasVCard4 ? GroupMethod.VCARD4 : GroupMethod.VCARD3_CATEGORIES;
App.log.info("Contact group method: " + groupMethod);
localAddressBook().includeGroups = !GroupMethod.VCARD3_CATEGORIES.equals(groupMethod);
} }
@Override @Override
protected RequestBody prepareUpload(LocalResource resource) throws IOException, ContactsStorageException { protected void prepareDirty() throws CalendarStorageException, ContactsStorageException {
LocalContact local = (LocalContact)resource; super.prepareDirty();
App.log.log(Level.FINE, "Preparing upload of contact " + local.getFileName(), local.getContact());
LocalAddressBook addressBook = localAddressBook();
if (GroupMethod.VCARD3_CATEGORIES.equals(groupMethod)) {
/* VCard3 group handling: groups memberships are represented as contact CATEGORIES */
// groups with DELETED=1: set all members to dirty, then remove group
for (LocalGroup group : addressBook.getDeletedGroups()) {
App.log.fine("Removing group " + group + " and marking its members as dirty");
group.markMembersDirty();
group.delete();
}
// groups with DIRTY=1: mark all memberships as dirty, then clean DIRTY flag of group
for (LocalGroup group : addressBook.getDirtyGroups()) {
App.log.fine("Marking members of modified group " + group + " as dirty");
group.markMembersDirty();
group.clearDirty(null);
}
} else {
/* VCard4 group handling: there are group contacts and individual contacts */
// mark groups with changed members as dirty
BatchOperation batch = new BatchOperation(addressBook.provider);
for (LocalContact contact : addressBook.getDirtyContacts())
try {
App.log.fine("Looking for changed group memberships of contact " + contact.getFileName());
Set<Long> cachedGroups = contact.getCachedGroupMemberships(),
currentGroups = contact.getGroupMemberships();
for (Long groupID : SetUtils.disjunction(cachedGroups, currentGroups)) {
App.log.fine("Marking group as dirty: " + groupID);
batch.enqueue(ContentProviderOperation
.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)))
.withValue(Groups.DIRTY, 1)
.build()
);
}
} catch(FileNotFoundException ignored) {
}
batch.commit();
}
}
@Override
protected RequestBody prepareUpload(@NonNull LocalResource resource) throws IOException, ContactsStorageException {
final Contact contact;
if (resource instanceof LocalContact) {
LocalContact local = ((LocalContact)resource);
contact = local.getContact();
if (groupMethod == GroupMethod.VCARD3_CATEGORIES) {
// VCard3: add groups as CATEGORIES
for (long groupID : local.getGroupMemberships()) {
try {
@Cleanup Cursor c = provider.query(
localAddressBook().syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)),
new String[] { Groups.TITLE },
null, null,
null
);
if (c != null && c.moveToNext()) {
String title = c.getString(0);
if (!TextUtils.isEmpty(title))
contact.categories.add(title);
}
} catch(RemoteException e) {
throw new ContactsStorageException("Couldn't find group for adding CATEGORIES", e);
}
}
}
} else if (resource instanceof LocalGroup)
contact = ((LocalGroup)resource).getContact();
else
throw new IllegalArgumentException("Argument must be LocalContact or LocalGroup");
App.log.log(Level.FINE, "Preparing upload of VCard " + resource.getFileName(), contact);
ByteArrayOutputStream os = new ByteArrayOutputStream(); ByteArrayOutputStream os = new ByteArrayOutputStream();
local.getContact().write(hasVCard4 ? VCardVersion.V4_0 : VCardVersion.V3_0, os); contact.write(hasVCard4 ? VCardVersion.V4_0 : VCardVersion.V3_0, groupMethod, os);
return RequestBody.create( return RequestBody.create(
hasVCard4 ? DavAddressBook.MIME_VCARD4 : DavAddressBook.MIME_VCARD3_UTF8, hasVCard4 ? DavAddressBook.MIME_VCARD4 : DavAddressBook.MIME_VCARD3_UTF8,
@ -213,21 +335,21 @@ public class ContactsSyncManager extends SyncManager {
// process multiget results // process multiget results
for (DavResource remote : davCollection.members) { for (DavResource remote : davCollection.members) {
String eTag; String eTag;
GetETag getETag = (GetETag) remote.properties.get(GetETag.NAME); GetETag getETag = (GetETag)remote.properties.get(GetETag.NAME);
if (getETag != null) if (getETag != null)
eTag = getETag.eTag; eTag = getETag.eTag;
else else
throw new DavException("Received multi-get response without ETag"); throw new DavException("Received multi-get response without ETag");
Charset charset = Charsets.UTF_8; Charset charset = Charsets.UTF_8;
GetContentType getContentType = (GetContentType) remote.properties.get(GetContentType.NAME); GetContentType getContentType = (GetContentType)remote.properties.get(GetContentType.NAME);
if (getContentType != null && getContentType.type != null) { if (getContentType != null && getContentType.type != null) {
MediaType type = MediaType.parse(getContentType.type); MediaType type = MediaType.parse(getContentType.type);
if (type != null) if (type != null)
charset = type.charset(Charsets.UTF_8); charset = type.charset(Charsets.UTF_8);
} }
AddressData addressData = (AddressData) remote.properties.get(AddressData.NAME); AddressData addressData = (AddressData)remote.properties.get(AddressData.NAME);
if (addressData == null || addressData.vCard == null) if (addressData == null || addressData.vCard == null)
throw new DavException("Received multi-get response without address data"); throw new DavException("Received multi-get response without address data");
@ -238,6 +360,22 @@ public class ContactsSyncManager extends SyncManager {
} }
} }
@Override
protected void postProcess() throws CalendarStorageException, ContactsStorageException {
if (groupMethod == GroupMethod.VCARD3_CATEGORIES) {
/* VCard3 group handling: groups memberships are represented as contact CATEGORIES */
// remove empty groups
App.log.info("Removing empty groups");
localAddressBook().removeEmptyGroups();
} else {
/* VCard4 group handling: there are group contacts and individual contacts */
App.log.info("Assigning memberships of downloaded contact groups");
LocalGroup.applyPendingMemberships(localAddressBook());
}
}
@Override @Override
protected void saveSyncState() throws CalendarStorageException, ContactsStorageException { protected void saveSyncState() throws CalendarStorageException, ContactsStorageException {
super.saveSyncState(); super.saveSyncState();
@ -250,48 +388,78 @@ public class ContactsSyncManager extends SyncManager {
private DavAddressBook davAddressBook() { return (DavAddressBook)davCollection; } private DavAddressBook davAddressBook() { return (DavAddressBook)davCollection; }
private LocalAddressBook localAddressBook() { return (LocalAddressBook)localCollection; } private LocalAddressBook localAddressBook() { return (LocalAddressBook)localCollection; }
private void processChangedGroups() throws ContactsStorageException {
LocalAddressBook addressBook = localAddressBook();
// groups with DELETED=1: remove group finally
for (LocalGroup group : addressBook.getDeletedGroups()) {
long groupId = group.getId();
App.log.fine("Finally removing group #" + groupId);
// remove group memberships, but not as sync adapter (should marks contacts as DIRTY)
// NOTE: doesn't work that way because Contact Provider removes the group memberships even for DELETED groups
// addressBook.removeGroupMemberships(groupId, false);
group.delete();
}
// groups with DIRTY=1: mark all memberships as dirty, then clean DIRTY flag of group
for (LocalGroup group : addressBook.getDirtyGroups()) {
long groupId = group.getId();
App.log.fine("Marking members of modified group #" + groupId + " as dirty");
addressBook.markMembersDirty(groupId);
group.clearDirty();
}
}
private void processVCard(String fileName, String eTag, InputStream stream, Charset charset, Contact.Downloader downloader) throws IOException, ContactsStorageException { private void processVCard(String fileName, String eTag, InputStream stream, Charset charset, Contact.Downloader downloader) throws IOException, ContactsStorageException {
App.log.info("Processing CardDAV resource " + fileName);
Contact[] contacts = Contact.fromStream(stream, charset, downloader); Contact[] contacts = Contact.fromStream(stream, charset, downloader);
if (contacts.length == 1) { if (contacts.length == 0) {
Contact newData = contacts[0]; App.log.warning("Received VCard without data, ignoring");
return;
} else if (contacts.length > 1)
App.log.warning("Received multiple VCards, using first one");
// update local contact, if it exists final Contact newData = contacts[0];
LocalContact localContact = (LocalContact)localResources.get(fileName);
if (localContact != null) { // update local contact, if it exists
App.log.info("Updating " + fileName + " in local address book"); LocalResource local = localResources.get(fileName);
localContact.eTag = eTag; if (local != null) {
localContact.update(newData); App.log.log(Level.INFO, "Updating " + fileName + " in local address book", newData);
if (local instanceof LocalGroup && newData.group) {
// update group
LocalGroup group = (LocalGroup)local;
group.eTag = eTag;
group.updateFromServer(newData);
syncResult.stats.numUpdates++; syncResult.stats.numUpdates++;
} else if (local instanceof LocalContact && !newData.group) {
// update contact
LocalContact contact = (LocalContact)local;
contact.eTag = eTag;
contact.update(newData);
syncResult.stats.numUpdates++;
} else { } else {
App.log.info("Adding " + fileName + " to local address book"); // group has become an individual contact or vice versa
localContact = new LocalContact(localAddressBook(), newData, fileName, eTag); try {
localContact.add(); local.delete();
syncResult.stats.numInserts++; local = null;
} catch(CalendarStorageException e) {
// CalendarStorageException is not used by LocalGroup and LocalContact
}
} }
} else }
App.log.severe("Received VCard with not exactly one VCARD, ignoring " + fileName);
if (local == null) {
if (newData.group) {
App.log.log(Level.INFO, "Creating local group", newData);
LocalGroup group = new LocalGroup(localAddressBook(), newData, fileName, eTag);
group.create();
local = group;
} else {
App.log.log(Level.INFO, "Creating local contact", newData);
LocalContact contact = new LocalContact(localAddressBook(), newData, fileName, eTag);
contact.create();
local = contact;
}
syncResult.stats.numInserts++;
}
if (groupMethod == GroupMethod.VCARD3_CATEGORIES && local instanceof LocalContact) {
// VCard3: update group memberships from CATEGORIES
LocalContact contact = (LocalContact)local;
BatchOperation batch = new BatchOperation(provider);
contact.removeGroupMemberships(batch);
for (String category : contact.getContact().categories) {
long groupID = localAddressBook().findOrCreateGroup(category);
contact.addToGroup(batch, groupID);
}
batch.commit();
}
} }

View File

@ -66,7 +66,8 @@ abstract public class SyncManager {
SYNC_PHASE_LIST_REMOTE = 7, SYNC_PHASE_LIST_REMOTE = 7,
SYNC_PHASE_COMPARE_LOCAL_REMOTE = 8, SYNC_PHASE_COMPARE_LOCAL_REMOTE = 8,
SYNC_PHASE_DOWNLOAD_REMOTE = 9, SYNC_PHASE_DOWNLOAD_REMOTE = 9,
SYNC_PHASE_SAVE_SYNC_STATE = 10; SYNC_PHASE_POST_PROCESSING = 10,
SYNC_PHASE_SAVE_SYNC_STATE = 11;
protected final NotificationManager notificationManager; protected final NotificationManager notificationManager;
protected final String uniqueCollectionId; protected final String uniqueCollectionId;
@ -169,6 +170,10 @@ abstract public class SyncManager {
App.log.info("Downloading remote entries"); App.log.info("Downloading remote entries");
downloadRemote(); downloadRemote();
syncPhase = SYNC_PHASE_POST_PROCESSING;
App.log.info("Post-processing");
postProcess();
syncPhase = SYNC_PHASE_SAVE_SYNC_STATE; syncPhase = SYNC_PHASE_SAVE_SYNC_STATE;
App.log.info("Saving sync state"); App.log.info("Saving sync state");
saveSyncState(); saveSyncState();
@ -278,9 +283,10 @@ abstract public class SyncManager {
protected void prepareDirty() throws CalendarStorageException, ContactsStorageException { 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 // 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()) { for (LocalResource local : localCollection.getWithoutFileName()) {
String uuid = UUID.randomUUID().toString(); String uuid = UUID.randomUUID().toString();
App.log.info("Found local record #" + local.getId() + " without file name; assigning file name/UID based on " + uuid); App.log.fine("Found local record #" + local.getId() + " without file name; assigning file name/UID based on " + uuid);
local.updateFileNameAndUID(uuid); local.updateFileNameAndUID(uuid);
} }
} }
@ -305,7 +311,6 @@ abstract public class SyncManager {
RequestBody body = prepareUpload(local); RequestBody body = prepareUpload(local);
try { try {
if (local.getETag() == null) { if (local.getETag() == null) {
App.log.info("Uploading new record " + fileName); App.log.info("Uploading new record " + fileName);
remote.put(body, null, true); remote.put(body, null, true);
@ -313,7 +318,6 @@ abstract public class SyncManager {
App.log.info("Uploading locally modified record " + fileName); App.log.info("Uploading locally modified record " + fileName);
remote.put(body, local.getETag(), false); remote.put(body, local.getETag(), false);
} }
} catch (ConflictException|PreconditionFailedException e) { } catch (ConflictException|PreconditionFailedException e) {
// we can't interact with the user to resolve the conflict, so we treat 409 like 412 // we can't interact with the user to resolve the conflict, so we treat 409 like 412
App.log.log(Level.INFO, "Resource has been modified on the server before upload, ignoring", e); App.log.log(Level.INFO, "Resource has been modified on the server before upload, ignoring", e);
@ -427,6 +431,12 @@ abstract public class SyncManager {
*/ */
abstract protected void downloadRemote() throws IOException, HttpException, DavException, ContactsStorageException, CalendarStorageException; abstract protected void downloadRemote() throws IOException, HttpException, DavException, ContactsStorageException, CalendarStorageException;
/**
* For post-processing of entries, for instance assigning groups.
*/
protected void postProcess() throws CalendarStorageException, ContactsStorageException {
}
protected void saveSyncState() throws CalendarStorageException, ContactsStorageException { protected void saveSyncState() throws CalendarStorageException, ContactsStorageException {
/* Save sync state (CTag). It doesn't matter if it has changed during the sync process /* 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 (for instance, because another client has uploaded changes), because this will simply

View File

@ -40,6 +40,9 @@ import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.ical4android.TaskProvider; import at.bitfire.ical4android.TaskProvider;
import lombok.Cleanup; import lombok.Cleanup;
/**
* Synchronization manager for CalDAV collections; handles tasks ({@code VTODO}).
*/
public class TasksSyncAdapterService extends SyncAdapterService { public class TasksSyncAdapterService extends SyncAdapterService {
@Override @Override

View File

@ -30,6 +30,7 @@ import at.bitfire.davdroid.App;
import at.bitfire.davdroid.InvalidAccountException; import at.bitfire.davdroid.InvalidAccountException;
import at.bitfire.davdroid.R; import at.bitfire.davdroid.R;
import at.bitfire.ical4android.TaskProvider; import at.bitfire.ical4android.TaskProvider;
import at.bitfire.vcard4android.GroupMethod;
public class AccountSettingsActivity extends AppCompatActivity { public class AccountSettingsActivity extends AppCompatActivity {
public final static String EXTRA_ACCOUNT = "account"; public final static String EXTRA_ACCOUNT = "account";
@ -209,7 +210,7 @@ public class AccountSettingsActivity extends AppCompatActivity {
}); });
// category: CalDAV // category: CalDAV
final EditTextPreference prefTimeRangePastDays = (EditTextPreference)findPreference("caldav_time_range_past_days"); final EditTextPreference prefTimeRangePastDays = (EditTextPreference)findPreference("time_range_past_days");
Integer pastDays = settings.getTimeRangePastDays(); Integer pastDays = settings.getTimeRangePastDays();
if (pastDays != null) { if (pastDays != null) {
prefTimeRangePastDays.setText(pastDays.toString()); prefTimeRangePastDays.setText(pastDays.toString());
@ -242,6 +243,18 @@ public class AccountSettingsActivity extends AppCompatActivity {
} }
}); });
// category: CardDAV
final ListPreference prefGroupMethod = (ListPreference)findPreference("contact_group_method");
prefGroupMethod.setValue(settings.getGroupMethod().name());
prefGroupMethod.setSummary(prefGroupMethod.getEntry());
prefGroupMethod.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
@Override
public boolean onPreferenceChange(Preference preference, Object o) {
String name = (String)o;
settings.setGroupMethod(GroupMethod.valueOf(name));
refresh(); return false;
}
});
} }
} }

View File

@ -179,6 +179,20 @@
<string name="settings_sync_wifi_only_ssid_on">Will only synchronize over %s</string> <string name="settings_sync_wifi_only_ssid_on">Will only synchronize over %s</string>
<string name="settings_sync_wifi_only_ssid_off">All WiFi connections may be used</string> <string name="settings_sync_wifi_only_ssid_off">All WiFi connections may be used</string>
<string name="settings_sync_wifi_only_ssid_message">Enter the name of a WiFi network (SSID) to restrict synchronization to this network, or leave blank for all WiFi connections.</string> <string name="settings_sync_wifi_only_ssid_message">Enter the name of a WiFi network (SSID) to restrict synchronization to this network, or leave blank for all WiFi connections.</string>
<string name="settings_carddav">CardDAV</string>
<string name="settings_contact_group_method">Contact group method</string>
<string-array name="settings_contact_group_method_values">
<item>AUTOMATIC</item>
<item>VCARD3_CATEGORIES</item>
<item>VCARD4</item>
<item>X_ADDRESSBOOK_SERVER</item>
</string-array>
<string-array name="settings_contact_group_method_entries">
<item>Automatic (VCard3/VCard4)</item>
<item>VCard3 only (CATEGORIES)</item>
<item>VCard4 only (KIND/MEMBER)</item>
<item>Apple (X-ADDRESSBOOK-SERVER)</item>
</string-array>
<string name="settings_caldav">CalDAV</string> <string name="settings_caldav">CalDAV</string>
<string name="settings_sync_time_range_past">Past event time limit</string> <string name="settings_sync_time_range_past">Past event time limit</string>
<string name="settings_sync_time_range_past_none">All events will be synchronized</string> <string name="settings_sync_time_range_past_none">All events will be synchronized</string>
@ -243,6 +257,7 @@
<item>listing remote entries</item> <item>listing remote entries</item>
<item>comparing local/remote entries</item> <item>comparing local/remote entries</item>
<item>downloading remote entries</item> <item>downloading remote entries</item>
<item>post-processing</item>
<item>saving sync state</item> <item>saving sync state</item>
</string-array> </string-array>
<string name="sync_error_unauthorized">User name/password wrong</string> <string name="sync_error_unauthorized">User name/password wrong</string>

View File

@ -74,10 +74,21 @@
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory android:title="@string/settings_carddav">
<ListPreference
android:key="contact_group_method"
android:persistent="false"
android:title="@string/settings_contact_group_method"
android:entries="@array/settings_contact_group_method_entries"
android:entryValues="@array/settings_contact_group_method_values"/>
</PreferenceCategory>
<PreferenceCategory android:title="@string/settings_caldav"> <PreferenceCategory android:title="@string/settings_caldav">
<EditTextPreference <EditTextPreference
android:key="caldav_time_range_past_days" android:key="time_range_past_days"
android:persistent="false" android:persistent="false"
android:title="@string/settings_sync_time_range_past" android:title="@string/settings_sync_time_range_past"
android:dialogMessage="@string/settings_sync_time_range_past_message" android:dialogMessage="@string/settings_sync_time_range_past_message"
@ -90,6 +101,6 @@
android:summaryOn="@string/settings_manage_calendar_colors_on" android:summaryOn="@string/settings_manage_calendar_colors_on"
android:summaryOff="@string/settings_manage_calendar_colors_off"/> android:summaryOff="@string/settings_manage_calendar_colors_off"/>
</PreferenceCategory> </PreferenceCategory>
</PreferenceScreen> </PreferenceScreen>

@ -1 +1 @@
Subproject commit 871d62fffc45cbe5aa061b4ed2b3bd5282a64dd1 Subproject commit fde96be29889d29f6ee796cf40f120fba6d50690

@ -1 +1 @@
Subproject commit baffbc628b9ca9733c1b97671d6ae8854cd06391 Subproject commit ee884d351b590e2f148561b17814db6d0d9e2a69

@ -1 +1 @@
Subproject commit 33cc8fbf59a114dbcaadfa3cc0fbba5fa01589d2 Subproject commit 5bca38c0300f7c18914985e9b42f38033e6aebd8