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.
+ *
+ * - 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()}.
+ * - 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.
+ *
+ *
+ */
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