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
pull/2/head
Ricki Hirner 8 years ago
parent 91234a688f
commit b30733c64b

@ -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) {

@ -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;
}

@ -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<LocalResource> all = new LinkedList<>();
Collections.addAll(all, (LocalResource[])queryContacts(null, null));
if (includeGroups)
Collections.addAll(all, (LocalResource[])queryGroups(null, null));
return all.toArray(new LocalResource[all.size()]);
}
/**
* Returns an array of local contacts which have been deleted locally. (DELETED != 0).
* Returns an array of local contacts/groups which have been deleted locally. (DELETED != 0).
*/
@Override
public LocalContact[] getDeleted() throws ContactsStorageException {
return (LocalContact[])queryContacts(RawContacts.DELETED + "!=0", null);
public LocalResource[] getDeleted() throws ContactsStorageException {
List<LocalResource> deleted = new LinkedList<>();
Collections.addAll(deleted, getDeletedContacts());
if (includeGroups)
Collections.addAll(deleted, getDeletedGroups());
return deleted.toArray(new LocalResource[deleted.size()]);
}
/**
* Returns an array of local contacts which have been changed locally (DIRTY != 0).
* Returns an array of local contacts/groups which have been changed locally (DIRTY != 0).
*/
@Override
public LocalContact[] getDirty() throws ContactsStorageException {
return (LocalContact[])queryContacts(RawContacts.DIRTY + "!=0", null);
public LocalResource[] getDirty() throws ContactsStorageException {
List<LocalResource> dirty = new LinkedList<>();
Collections.addAll(dirty, getDirtyContacts());
if (includeGroups)
Collections.addAll(dirty, getDirtyGroups());
return dirty.toArray(new LocalResource[dirty.size()]);
}
/**
* Returns an array of local contacts which don't have a file name yet.
*/
@Override
public LocalContact[] getWithoutFileName() throws ContactsStorageException {
return (LocalContact[])queryContacts(AndroidContact.COLUMN_FILENAME + " IS NULL", null);
public LocalResource[] getWithoutFileName() throws ContactsStorageException {
List<LocalResource> nameless = new LinkedList<>();
Collections.addAll(nameless, (LocalContact[])queryContacts(AndroidContact.COLUMN_FILENAME + " IS NULL", null));
if (includeGroups)
Collections.addAll(nameless, (LocalGroup[])queryGroups(AndroidGroup.COLUMN_FILENAME + " IS NULL", null));
return nameless.toArray(new LocalResource[nameless.size()]);
}
public void deleteAll() throws ContactsStorageException {
public void deleteAll() throws ContactsStorageException {
try {
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);
}
/**
* 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
*/
@SuppressWarnings("Recycle")
public LocalGroup findGroupByTitle(String displayName) throws ContactsStorageException {
try {
@Cleanup Cursor cursor = provider.query(syncAdapterURI(Groups.CONTENT_URI),
new String[] { Groups._ID },
ContactsContract.Groups.TITLE + "=?", new String[] { displayName }, null);
if (cursor != null && cursor.moveToNext())
return new LocalGroup(this, cursor.getLong(0));
} catch (RemoteException e) {
throw new ContactsStorageException("Couldn't find local contact group", e);
}
return null;
public LocalContact[] getDirtyContacts() throws ContactsStorageException {
return (LocalContact[])queryContacts(RawContacts.DIRTY + "!= 0", null);
}
@SuppressWarnings("Recycle")
public LocalGroup[] getDeletedGroups() throws ContactsStorageException {
List<LocalGroup> 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()]);
return (LocalGroup[])queryGroups(Groups.DELETED + "!= 0", null);
}
@SuppressWarnings("Recycle")
public LocalGroup[] getDirtyGroups() throws ContactsStorageException {
List<LocalGroup> groups = new LinkedList<>();
return (LocalGroup[])queryGroups(Groups.DIRTY + "!= 0", null);
}
/**
* 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
*/
public long findOrCreateGroup(@NonNull String title) throws ContactsStorageException {
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);
new String[] { Groups._ID },
Groups.TITLE + "=?", new String[] { title },
null);
if (cursor != null && cursor.moveToNext())
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 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();
}

@ -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<Long>
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);
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());
}
protected void insertDataRows(BatchOperation batch) throws ContactsStorageException {
super.insertDataRows(batch);
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<Long> getCachedGroupMemberships() throws ContactsStorageException, FileNotFoundException {
getContact();
return cachedGroupMemberships;
}
/**
* Returns the IDs of all groups the contact is member of.
* @return set of {@link GroupMembership#GROUP_ROW_ID}s (may be empty)
* @throws ContactsStorageException on contact provider errors
* @throws FileNotFoundException if the current contact can't be found
*/
@NonNull
public Set<Long> getGroupMemberships() throws ContactsStorageException, FileNotFoundException {
getContact();
return groupMemberships;
}
@ -134,6 +184,7 @@ public class LocalContact extends AndroidContact implements LocalResource {
return new LocalContact(addressBook, contact, fileName, eTag);
}
@Override
public LocalContact[] newArray(int size) {
return new LocalContact[size];
}

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

@ -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;

@ -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;
/**
* <p>Synchronization manager for CardDAV collections; handles contacts and groups.</p>
*
* <p></p>Group handling differs according to the {@link #groupMethod}. There are two basic methods to
* handle/manage groups:</p>
* <ul>
* <li>VCard3 {@code CATEGORIES}: groups memberships are attached to each contact and represented as
* "category". When a group is dirty or has been deleted, all its members have to be set to
* dirty, too (because they have to be uploaded without the respective category). This
* is done in {@link #prepareDirty()}. Empty groups can be deleted without further processing,
* which is done in {@link #postProcess()} because groups may become empty after downloading
* updated remoted contacts.</li>
* <li>VCard4-style: individual and group contacts (with a list of member UIDs) are
* distinguished. When a local group is dirty, its members don't need to be set to dirty.
* <ol>
* <li>However, when a contact is dirty, it has
* to be checked whether its group memberships have changed. In this case, the respective
* groups have to be set to dirty. For instance, if contact A is in group G and H, and then
* group membership of G is removed, the contact will be set to dirty because of the changed
* {@link android.provider.ContactsContract.CommonDataKinds.GroupMembership}. DAVdroid will
* then have to check whether the group memberships have actually changed, and if so,
* all affected groups have to be set to dirty. To detect changes in group memberships,
* DAVdroid always mirrors all {@link android.provider.ContactsContract.CommonDataKinds.GroupMembership}
* data rows in respective {@link at.bitfire.vcard4android.CachedGroupMembership} rows.
* If the cached group memberships are not the same as the current group member ships, the
* difference set (in our example G, because its in the cached memberships, but not in the
* actual ones) is marked as dirty. This is done in {@link #prepareDirty()}.</li>
* <li>When downloading remote contacts, groups (+ member information) may be received
* by the actual members. Thus, the member lists have to be cached until all VCards
* are received. This is done by caching the member UIDs of each group in
* {@link LocalGroup#COLUMN_PENDING_MEMBERS}. In {@link #postProcess()},
* these "pending memberships" are assigned to the actual contacs and then cleaned up.</li>
* </ol>
* </ul>
*/
public class ContactsSyncManager extends SyncManager {
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 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<Long> cachedGroups = contact.getCachedGroupMemberships(),
currentGroups = contact.getGroupMemberships();
for (Long groupID : SetUtils.disjunction(cachedGroups, currentGroups)) {
App.log.fine("Marking group as dirty: " + groupID);
batch.enqueue(ContentProviderOperation
.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)))
.withValue(Groups.DIRTY, 1)
.build()
);
}
} catch(FileNotFoundException ignored) {
}
batch.commit();
}
}
@Override
protected RequestBody prepareUpload(LocalResource resource) throws IOException, ContactsStorageException {
LocalContact local = (LocalContact)resource;
App.log.log(Level.FINE, "Preparing upload of contact " + local.getFileName(), local.getContact());
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();
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 == 0) {
App.log.warning("Received VCard without data, ignoring");
return;
} else if (contacts.length > 1)
App.log.warning("Received multiple VCards, using first one");
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++;
// 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();
}
} else if (local instanceof LocalContact && !newData.group) {
// update contact
LocalContact contact = (LocalContact)local;
contact.eTag = eTag;
contact.update(newData);
syncResult.stats.numUpdates++;
// 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();
} else {
// 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
}
}
}
}
private void processVCard(String fileName, String eTag, InputStream stream, Charset charset, Contact.Downloader downloader) throws IOException, ContactsStorageException {
Contact[] contacts = Contact.fromStream(stream, charset, downloader);
if (contacts.length == 1) {
Contact newData = contacts[0];
// 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);
syncResult.stats.numUpdates++;
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.info("Adding " + fileName + " to local address book");
localContact = new LocalContact(localAddressBook(), newData, fileName, eTag);
localContact.add();
syncResult.stats.numInserts++;
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);
}
} else
App.log.severe("Received VCard with not exactly one VCARD, ignoring " + fileName);
batch.commit();
}
}

@ -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

@ -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

@ -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;
}
});
}
}

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

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

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

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

@ -1 +1 @@
Subproject commit 33cc8fbf59a114dbcaadfa3cc0fbba5fa01589d2
Subproject commit 5bca38c0300f7c18914985e9b42f38033e6aebd8
Loading…
Cancel
Save