From b30733c64b16cd29ff50540bc15dfab3dfc4df7e Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Wed, 8 Jun 2016 21:44:31 +0200 Subject: [PATCH] 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 --- .../at/bitfire/davdroid/AccountSettings.java | 28 +- .../davdroid/model/UnknownProperties.java | 22 ++ .../davdroid/resource/LocalAddressBook.java | 168 ++++++----- .../davdroid/resource/LocalContact.java | 135 ++++++--- .../bitfire/davdroid/resource/LocalGroup.java | 229 +++++++++++++- .../syncadapter/CalendarSyncManager.java | 3 + .../syncadapter/ContactsSyncManager.java | 284 ++++++++++++++---- .../davdroid/syncadapter/SyncManager.java | 18 +- .../syncadapter/TasksSyncAdapterService.java | 3 + .../davdroid/ui/AccountSettingsActivity.java | 15 +- app/src/main/res/values/strings.xml | 15 + app/src/main/res/xml/settings_account.xml | 15 +- dav4android | 2 +- ical4android | 2 +- vcard4android | 2 +- 15 files changed, 741 insertions(+), 200 deletions(-) create mode 100644 app/src/main/java/at/bitfire/davdroid/model/UnknownProperties.java diff --git a/app/src/main/java/at/bitfire/davdroid/AccountSettings.java b/app/src/main/java/at/bitfire/davdroid/AccountSettings.java index 0ea7ccfc..27a1d892 100644 --- a/app/src/main/java/at/bitfire/davdroid/AccountSettings.java +++ b/app/src/main/java/at/bitfire/davdroid/AccountSettings.java @@ -45,6 +45,7 @@ import at.bitfire.davdroid.resource.LocalTaskList; import at.bitfire.ical4android.CalendarStorageException; import at.bitfire.ical4android.TaskProvider; import at.bitfire.vcard4android.ContactsStorageException; +import at.bitfire.vcard4android.GroupMethod; import lombok.Cleanup; import okhttp3.HttpUrl; @@ -72,6 +73,14 @@ public class AccountSettings { "0" false */ 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; final Context context; @@ -184,6 +193,7 @@ public class AccountSettings { // CalDAV settings + @Nullable public Integer getTimeRangePastDays() { String strDays = accountManager.getUserData(account, KEY_TIME_RANGE_PAST_DAYS); if (strDays != null) { @@ -193,7 +203,7 @@ public class AccountSettings { 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)); } @@ -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 private void update(int fromVersion) { diff --git a/app/src/main/java/at/bitfire/davdroid/model/UnknownProperties.java b/app/src/main/java/at/bitfire/davdroid/model/UnknownProperties.java new file mode 100644 index 00000000..9c1fb060 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/model/UnknownProperties.java @@ -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; + +} diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalAddressBook.java b/app/src/main/java/at/bitfire/davdroid/resource/LocalAddressBook.java index a89a3b10..0a5d224c 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalAddressBook.java +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalAddressBook.java @@ -12,22 +12,22 @@ import android.content.ContentProviderClient; import android.content.ContentUris; import android.content.ContentValues; import android.database.Cursor; +import android.net.Uri; import android.os.Bundle; import android.os.Parcel; 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.RawContacts; +import android.support.annotation.NonNull; +import java.io.FileNotFoundException; +import java.util.Collections; import java.util.LinkedList; import java.util.List; -import at.bitfire.davdroid.App; import at.bitfire.vcard4android.AndroidAddressBook; import at.bitfire.vcard4android.AndroidContact; -import at.bitfire.vcard4android.AndroidGroupFactory; +import at.bitfire.vcard4android.AndroidGroup; import at.bitfire.vcard4android.ContactsStorageException; import lombok.Cleanup; @@ -40,122 +40,128 @@ public class LocalAddressBook extends AndroidAddressBook implements LocalCollect 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) { - 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 - public LocalContact[] getAll() throws ContactsStorageException { - return (LocalContact[])queryContacts(null, null); + public LocalResource[] getAll() throws ContactsStorageException { + List 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 - public LocalContact[] getDeleted() throws ContactsStorageException { - return (LocalContact[])queryContacts(RawContacts.DELETED + "!=0", null); + public LocalResource[] getDeleted() throws ContactsStorageException { + List 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 - public LocalContact[] getDirty() throws ContactsStorageException { - return (LocalContact[])queryContacts(RawContacts.DIRTY + "!=0", null); + public LocalResource[] getDirty() throws ContactsStorageException { + List 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. */ @Override - public LocalContact[] getWithoutFileName() throws ContactsStorageException { - return (LocalContact[])queryContacts(AndroidContact.COLUMN_FILENAME + " IS NULL", null); + public LocalResource[] getWithoutFileName() throws ContactsStorageException { + List 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 { provider.delete(syncAdapterURI(RawContacts.CONTENT_URI), null, null); - } catch (RemoteException e) { - throw new ContactsStorageException("Couldn't delete all local contacts", e); + provider.delete(syncAdapterURI(Groups.CONTENT_URI), null, null); + } 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. - * @param displayName title of the group to look for - * @return group with given title, or null if none + * Finds the first group with the given title. If there is no group with this + * title, a new group is created. + * @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 LocalGroup findGroupByTitle(String displayName) throws ContactsStorageException { + public long findOrCreateGroup(@NonNull String title) throws ContactsStorageException { try { @Cleanup Cursor cursor = provider.query(syncAdapterURI(Groups.CONTENT_URI), - new String[] { Groups._ID }, - ContactsContract.Groups.TITLE + "=?", new String[] { displayName }, null); + new String[] { Groups._ID }, + Groups.TITLE + "=?", new String[] { title }, + null); if (cursor != null && cursor.moveToNext()) - return new LocalGroup(this, cursor.getLong(0)); - } catch (RemoteException e) { + return cursor.getLong(0); + + 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); } - return null; } - @SuppressWarnings("Recycle") - public LocalGroup[] getDeletedGroups() throws ContactsStorageException { - List groups = new LinkedList<>(); - try { - @Cleanup Cursor cursor = provider.query(syncAdapterURI(Groups.CONTENT_URI), - new String[] { Groups._ID }, - 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 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); - } + public void removeEmptyGroups() throws ContactsStorageException { + // find groups without members + /** should be done using {@link Groups.SUMMARY_COUNT}, but it's not implemented in Android yet */ + for (LocalGroup group : (LocalGroup[])queryGroups(null, null)) + if (group.getMembers().length == 0) + group.delete(); } diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalContact.java b/app/src/main/java/at/bitfire/davdroid/resource/LocalContact.java index 4d7e3442..5cd37328 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalContact.java +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalContact.java @@ -13,16 +13,20 @@ import android.content.ContentValues; import android.os.RemoteException; import android.provider.ContactsContract; import android.provider.ContactsContract.CommonDataKinds.GroupMembership; +import android.provider.ContactsContract.RawContacts.Data; +import android.support.annotation.NonNull; 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.model.UnknownProperties; import at.bitfire.vcard4android.AndroidAddressBook; import at.bitfire.vcard4android.AndroidContact; import at.bitfire.vcard4android.AndroidContactFactory; import at.bitfire.vcard4android.BatchOperation; +import at.bitfire.vcard4android.CachedGroupMembership; import at.bitfire.vcard4android.Contact; import at.bitfire.vcard4android.ContactsStorageException; 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; } + protected final Set + cachedGroupMemberships = new HashSet<>(), + groupMemberships = new HashSet<>(); + + protected LocalContact(AndroidAddressBook addressBook, long id, String fileName, String eTag) { super(addressBook, id, fileName, eTag); } @@ -69,53 +78,94 @@ public class LocalContact extends AndroidContact implements LocalResource { } - // group support - @Override - protected void populateGroupMembership(ContentValues row) { - if (row.containsKey(GroupMembership.GROUP_ROW_ID)) { - long groupId = row.getAsLong(GroupMembership.GROUP_ROW_ID); - - // fetch group - LocalGroup group = new LocalGroup(addressBook, groupId); - try { - Contact groupInfo = group.getContact(); - - // add to CATEGORIES - contact.categories.add(groupInfo.displayName); - } catch (FileNotFoundException|ContactsStorageException e) { - App.log.log(Level.WARNING, "Couldn't find assigned group #" + groupId + ", ignoring membership", e); - } + protected void populateData(String mimeType, ContentValues row) { + switch (mimeType) { + case CachedGroupMembership.CONTENT_ITEM_TYPE: + cachedGroupMemberships.add(row.getAsLong(CachedGroupMembership.GROUP_ID)); + break; + case GroupMembership.CONTENT_ITEM_TYPE: + groupMemberships.add(row.getAsLong(GroupMembership.GROUP_ROW_ID)); + break; + case UnknownProperties.CONTENT_ITEM_TYPE: + contact.unknownProperties = row.getAsString(UnknownProperties.UNKNOWN_PROPERTIES); + break; } } @Override - protected void insertGroupMemberships(BatchOperation batch) throws ContactsStorageException { - for (String category : contact.categories) { - // Is there already a category with this display name? - LocalGroup group = ((LocalAddressBook)addressBook).findGroupByTitle(category); + protected void insertDataRows(BatchOperation batch) throws ContactsStorageException { + super.insertDataRows(batch); - if (group == null) { - // no, we have to create the group before inserting the membership - - Contact groupInfo = new Contact(); - groupInfo.displayName = category; - group = new LocalGroup(addressBook, groupInfo); - group.create(); - } - - 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()); - } + if (contact.unknownProperties != null) { + ContentProviderOperation.Builder builder = ContentProviderOperation.newInsert(dataSyncURI()); + if (id == null) + builder.withValueBackReference(UnknownProperties.RAW_CONTACT_ID, 0); + else + builder.withValue(UnknownProperties.RAW_CONTACT_ID, id); + builder .withValue(UnknownProperties.MIMETYPE, UnknownProperties.CONTENT_ITEM_TYPE) + .withValue(UnknownProperties.UNKNOWN_PROPERTIES, contact.unknownProperties); + 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 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 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); } + @Override public LocalContact[] newArray(int size) { return new LocalContact[size]; } diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalGroup.java b/app/src/main/java/at/bitfire/davdroid/resource/LocalGroup.java index f0dc7874..a083058b 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalGroup.java +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalGroup.java @@ -8,28 +8,241 @@ package at.bitfire.davdroid.resource; +import android.content.ContentProviderOperation; +import android.content.ContentUris; import android.content.ContentValues; +import android.database.Cursor; +import android.os.Parcel; +import android.os.RemoteException; 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.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.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) { - super(addressBook, id); + public LocalGroup(AndroidAddressBook addressBook, long id, String fileName, String eTag) { + super(addressBook, id, fileName, eTag); } - public LocalGroup(AndroidAddressBook addressBook, Contact contact) { - super(addressBook, contact); + public LocalGroup(AndroidAddressBook addressBook, Contact contact, String fileName, String eTag) { + super(addressBook, contact, fileName, eTag); } - public void clearDirty() throws ContactsStorageException { - ContentValues values = new ContentValues(1); - values.put(ContactsContract.Groups.DIRTY, 0); + + @Override + 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 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 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 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]; + } + } } diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncManager.java b/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncManager.java index 5e216d3d..4c4b215a 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncManager.java +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncManager.java @@ -55,6 +55,9 @@ import okhttp3.MediaType; import okhttp3.RequestBody; import okhttp3.ResponseBody; +/** + * Synchronization manager for CalDAV collections; handles events ({@code VEVENT}). + */ public class CalendarSyncManager extends SyncManager { protected static final int MAX_MULTIGET = 20; diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncManager.java b/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncManager.java index 7ce1306b..e6949445 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncManager.java +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncManager.java @@ -10,26 +10,33 @@ package at.bitfire.davdroid.syncadapter; import android.accounts.Account; import android.content.ContentProviderClient; +import android.content.ContentProviderOperation; +import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.content.SyncResult; +import android.database.Cursor; import android.os.Bundle; -import android.os.Environment; +import android.os.RemoteException; 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.collections4.SetUtils; import org.apache.commons.lang3.StringUtils; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.FileOutputStream; +import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.nio.charset.Charset; import java.util.HashMap; import java.util.LinkedList; import java.util.List; +import java.util.Set; import java.util.logging.Level; 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.LocalResource; import at.bitfire.ical4android.CalendarStorageException; +import at.bitfire.vcard4android.BatchOperation; import at.bitfire.vcard4android.Contact; import at.bitfire.vcard4android.ContactsStorageException; +import at.bitfire.vcard4android.GroupMethod; import ezvcard.VCardVersion; import ezvcard.util.IOUtils; import lombok.Cleanup; @@ -68,12 +77,49 @@ import okhttp3.RequestBody; import okhttp3.Response; import okhttp3.ResponseBody; +/** + *

Synchronization manager for CardDAV collections; handles contacts and groups.

+ * + *

Group handling differs according to the {@link #groupMethod}. There are two basic methods to + * handle/manage groups:

+ *
    + *
  • 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.
  • + *
  • 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. + *
      + *
    1. 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()}.
    2. + *
    3. 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.
    4. + *
    + *
+ */ public class ContactsSyncManager extends SyncManager { protected static final int MAX_MULTIGET = 10; final private ContentProviderClient provider; final private CollectionInfo remote; + 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 { @@ -107,37 +153,113 @@ public class ContactsSyncManager extends SyncManager { } // set up Contacts Provider Settings - ContentValues settings = new ContentValues(2); - settings.put(ContactsContract.Settings.SHOULD_SYNC, 1); - settings.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1); - localAddressBook.updateSettings(settings); + ContentValues values = new ContentValues(2); + values.put(ContactsContract.Settings.SHOULD_SYNC, 1); + values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1); + localAddressBook.updateSettings(values); collectionURL = HttpUrl.parse(url); davCollection = new DavAddressBook(httpClient, collectionURL); - - processChangedGroups(); } @Override protected void queryCapabilities() throws DavException, IOException, HttpException { // prepare remote address book - hasVCard4 = false; davCollection.propfind(0, SupportedAddressData.NAME, GetCTag.NAME); - SupportedAddressData supportedAddressData = (SupportedAddressData) davCollection.properties.get(SupportedAddressData.NAME); - if (supportedAddressData != null) - for (MediaType type : supportedAddressData.types) - if ("text/vcard; version=4.0".equalsIgnoreCase(type.toString())) - hasVCard4 = true; + SupportedAddressData supportedAddressData = (SupportedAddressData)davCollection.properties.get(SupportedAddressData.NAME); + hasVCard4 = supportedAddressData != null && supportedAddressData.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 - protected RequestBody prepareUpload(LocalResource resource) throws IOException, ContactsStorageException { - LocalContact local = (LocalContact)resource; - App.log.log(Level.FINE, "Preparing upload of contact " + local.getFileName(), local.getContact()); + protected void prepareDirty() throws CalendarStorageException, ContactsStorageException { + super.prepareDirty(); + + 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 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(); - 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( hasVCard4 ? DavAddressBook.MIME_VCARD4 : DavAddressBook.MIME_VCARD3_UTF8, @@ -213,21 +335,21 @@ public class ContactsSyncManager extends SyncManager { // process multiget results for (DavResource remote : davCollection.members) { String eTag; - GetETag getETag = (GetETag) remote.properties.get(GetETag.NAME); + GetETag getETag = (GetETag)remote.properties.get(GetETag.NAME); if (getETag != null) eTag = getETag.eTag; else throw new DavException("Received multi-get response without ETag"); 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) { MediaType type = MediaType.parse(getContentType.type); if (type != null) 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) 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 protected void saveSyncState() throws CalendarStorageException, ContactsStorageException { super.saveSyncState(); @@ -250,48 +388,78 @@ public class ContactsSyncManager extends SyncManager { private DavAddressBook davAddressBook() { return (DavAddressBook)davCollection; } 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 { + App.log.info("Processing CardDAV resource " + fileName); Contact[] contacts = Contact.fromStream(stream, charset, downloader); - if (contacts.length == 1) { - Contact newData = contacts[0]; + if (contacts.length == 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 - LocalContact localContact = (LocalContact)localResources.get(fileName); - if (localContact != null) { - App.log.info("Updating " + fileName + " in local address book"); - localContact.eTag = eTag; - localContact.update(newData); + final Contact newData = contacts[0]; + + // update local contact, if it exists + LocalResource local = localResources.get(fileName); + if (local != null) { + 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++; + + } else if (local instanceof LocalContact && !newData.group) { + // update contact + LocalContact contact = (LocalContact)local; + contact.eTag = eTag; + contact.update(newData); + syncResult.stats.numUpdates++; + } else { - App.log.info("Adding " + fileName + " to local address book"); - localContact = new LocalContact(localAddressBook(), newData, fileName, eTag); - localContact.add(); - syncResult.stats.numInserts++; + // group has become an individual contact or vice versa + try { + local.delete(); + 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(); + } } diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncManager.java b/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncManager.java index 84920bc0..cecf066a 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncManager.java +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncManager.java @@ -66,7 +66,8 @@ abstract public class SyncManager { SYNC_PHASE_LIST_REMOTE = 7, SYNC_PHASE_COMPARE_LOCAL_REMOTE = 8, 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 String uniqueCollectionId; @@ -169,6 +170,10 @@ abstract public class SyncManager { App.log.info("Downloading remote entries"); downloadRemote(); + syncPhase = SYNC_PHASE_POST_PROCESSING; + App.log.info("Post-processing"); + postProcess(); + syncPhase = SYNC_PHASE_SAVE_SYNC_STATE; App.log.info("Saving sync state"); saveSyncState(); @@ -278,9 +283,10 @@ abstract public class SyncManager { protected void prepareDirty() throws CalendarStorageException, ContactsStorageException { // assign file names and UIDs to new contacts so that we can use the file name as an index + App.log.info("Looking for contacts/groups without file name"); for (LocalResource local : localCollection.getWithoutFileName()) { String uuid = UUID.randomUUID().toString(); - App.log.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); } } @@ -305,7 +311,6 @@ abstract public class SyncManager { RequestBody body = prepareUpload(local); try { - if (local.getETag() == null) { App.log.info("Uploading new record " + fileName); remote.put(body, null, true); @@ -313,7 +318,6 @@ abstract public class SyncManager { App.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 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; + /** + * For post-processing of entries, for instance assigning groups. + */ + protected void postProcess() 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 (for instance, because another client has uploaded changes), because this will simply diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncAdapterService.java b/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncAdapterService.java index 85448eb8..648e39ac 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncAdapterService.java +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncAdapterService.java @@ -40,6 +40,9 @@ import at.bitfire.ical4android.CalendarStorageException; import at.bitfire.ical4android.TaskProvider; import lombok.Cleanup; +/** + * Synchronization manager for CalDAV collections; handles tasks ({@code VTODO}). + */ public class TasksSyncAdapterService extends SyncAdapterService { @Override diff --git a/app/src/main/java/at/bitfire/davdroid/ui/AccountSettingsActivity.java b/app/src/main/java/at/bitfire/davdroid/ui/AccountSettingsActivity.java index 52169013..93d711b1 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/AccountSettingsActivity.java +++ b/app/src/main/java/at/bitfire/davdroid/ui/AccountSettingsActivity.java @@ -30,6 +30,7 @@ import at.bitfire.davdroid.App; import at.bitfire.davdroid.InvalidAccountException; import at.bitfire.davdroid.R; import at.bitfire.ical4android.TaskProvider; +import at.bitfire.vcard4android.GroupMethod; public class AccountSettingsActivity extends AppCompatActivity { public final static String EXTRA_ACCOUNT = "account"; @@ -209,7 +210,7 @@ public class AccountSettingsActivity extends AppCompatActivity { }); // 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(); if (pastDays != null) { 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; + } + }); } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 939d464e..f456c60e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -179,6 +179,20 @@ Will only synchronize over %s All WiFi connections may be used Enter the name of a WiFi network (SSID) to restrict synchronization to this network, or leave blank for all WiFi connections. + CardDAV + Contact group method + + AUTOMATIC + VCARD3_CATEGORIES + VCARD4 + X_ADDRESSBOOK_SERVER + + + Automatic (VCard3/VCard4) + VCard3 only (CATEGORIES) + VCard4 only (KIND/MEMBER) + Apple (X-ADDRESSBOOK-SERVER) + CalDAV Past event time limit All events will be synchronized @@ -243,6 +257,7 @@ listing remote entries comparing local/remote entries downloading remote entries + post-processing saving sync state User name/password wrong diff --git a/app/src/main/res/xml/settings_account.xml b/app/src/main/res/xml/settings_account.xml index 87cfb3a1..0e94fe89 100644 --- a/app/src/main/res/xml/settings_account.xml +++ b/app/src/main/res/xml/settings_account.xml @@ -74,10 +74,21 @@ + + + + + + - + diff --git a/dav4android b/dav4android index 871d62ff..fde96be2 160000 --- a/dav4android +++ b/dav4android @@ -1 +1 @@ -Subproject commit 871d62fffc45cbe5aa061b4ed2b3bd5282a64dd1 +Subproject commit fde96be29889d29f6ee796cf40f120fba6d50690 diff --git a/ical4android b/ical4android index baffbc62..ee884d35 160000 --- a/ical4android +++ b/ical4android @@ -1 +1 @@ -Subproject commit baffbc628b9ca9733c1b97671d6ae8854cd06391 +Subproject commit ee884d351b590e2f148561b17814db6d0d9e2a69 diff --git a/vcard4android b/vcard4android index 33cc8fbf..5bca38c0 160000 --- a/vcard4android +++ b/vcard4android @@ -1 +1 @@ -Subproject commit 33cc8fbf59a114dbcaadfa3cc0fbba5fa01589d2 +Subproject commit 5bca38c0300f7c18914985e9b42f38033e6aebd8