mirror of
https://github.com/etesync/android
synced 2024-11-26 01:48:34 +00:00
Basic support for VCard4-style groups
* rewritten contact group support to support VCard3 CATEGORIES and VCard4-style KIND/MEMBER groups * new account setting: contact group method (VCard3/VCard4/Apple "VCard4-as-VCard3") * keep unknown properties when saving/generating VCards
This commit is contained in:
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);
|
||||
}
|
||||
|
||||
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<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()]);
|
||||
}
|
||||
|
||||
@SuppressWarnings("Recycle")
|
||||
public LocalGroup[] getDirtyGroups() throws ContactsStorageException {
|
||||
List<LocalGroup> groups = new LinkedList<>();
|
||||
try {
|
||||
@Cleanup Cursor cursor = provider.query(syncAdapterURI(Groups.CONTENT_URI),
|
||||
new String[] { Groups._ID },
|
||||
Groups.DIRTY + "!=0", null, null);
|
||||
while (cursor != null && cursor.moveToNext())
|
||||
groups.add(new LocalGroup(this, cursor.getLong(0)));
|
||||
} catch (RemoteException e) {
|
||||
throw new ContactsStorageException("Couldn't query dirty groups", e);
|
||||
}
|
||||
return groups.toArray(new LocalGroup[groups.size()]);
|
||||
}
|
||||
|
||||
@SuppressWarnings("Recycle")
|
||||
public void markMembersDirty(long groupId) throws ContactsStorageException {
|
||||
ContentValues dirty = new ContentValues(1);
|
||||
dirty.put(RawContacts.DIRTY, 1);
|
||||
try {
|
||||
// query all GroupMemberships of this groupId, mark every corresponding raw contact as DIRTY
|
||||
@Cleanup Cursor cursor = provider.query(syncAdapterURI(Data.CONTENT_URI),
|
||||
new String[] { GroupMembership.RAW_CONTACT_ID },
|
||||
Data.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?",
|
||||
new String[] { GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(groupId) }, null);
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
long id = cursor.getLong(0);
|
||||
App.log.fine("Marking raw contact #" + id + " as dirty");
|
||||
provider.update(syncAdapterURI(ContentUris.withAppendedId(RawContacts.CONTENT_URI, id)), dirty, null, null);
|
||||
}
|
||||
} catch (RemoteException e) {
|
||||
throw new ContactsStorageException("Couldn't query dirty groups", e);
|
||||
}
|
||||
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);
|
||||
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<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;
|
||||
|
||||
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<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 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<Long> cachedGroups = contact.getCachedGroupMemberships(),
|
||||
currentGroups = contact.getGroupMemberships();
|
||||
for (Long groupID : SetUtils.disjunction(cachedGroups, currentGroups)) {
|
||||
App.log.fine("Marking group as dirty: " + groupID);
|
||||
batch.enqueue(ContentProviderOperation
|
||||
.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)))
|
||||
.withValue(Groups.DIRTY, 1)
|
||||
.build()
|
||||
);
|
||||
}
|
||||
} catch(FileNotFoundException ignored) {
|
||||
}
|
||||
batch.commit();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected RequestBody prepareUpload(@NonNull LocalResource resource) throws IOException, ContactsStorageException {
|
||||
final Contact contact;
|
||||
if (resource instanceof LocalContact) {
|
||||
LocalContact local = ((LocalContact)resource);
|
||||
contact = local.getContact();
|
||||
|
||||
if (groupMethod == GroupMethod.VCARD3_CATEGORIES) {
|
||||
// VCard3: add groups as CATEGORIES
|
||||
for (long groupID : local.getGroupMemberships()) {
|
||||
try {
|
||||
@Cleanup Cursor c = provider.query(
|
||||
localAddressBook().syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)),
|
||||
new String[] { Groups.TITLE },
|
||||
null, null,
|
||||
null
|
||||
);
|
||||
if (c != null && c.moveToNext()) {
|
||||
String title = c.getString(0);
|
||||
if (!TextUtils.isEmpty(title))
|
||||
contact.categories.add(title);
|
||||
}
|
||||
} catch(RemoteException e) {
|
||||
throw new ContactsStorageException("Couldn't find group for adding CATEGORIES", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (resource instanceof LocalGroup)
|
||||
contact = ((LocalGroup)resource).getContact();
|
||||
else
|
||||
throw new IllegalArgumentException("Argument must be LocalContact or LocalGroup");
|
||||
|
||||
App.log.log(Level.FINE, "Preparing upload of VCard " + resource.getFileName(), contact);
|
||||
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
@ -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…
Reference in New Issue
Block a user