Retain unknown VCard properties, ez-vcard update, handle stale connections

* store unknown VCard properties in an extra column and load them when generating a new VCard (closes #118)
* upgrade to ez-vcard/0.9.3 (fixes sync error reported via Play Store)
* (re-)enable stale collection check, RetryHandler to retry idempotent CalDAV/CardDAV requests (hopefully fixes #225)
* always set FN/display name (take organization if no structured name is available) (hopefully fixes #227)
pull/2/head
rfc2822 10 years ago
parent 4c05dd1e45
commit 1e2051038c

@ -35,6 +35,7 @@ import ezvcard.property.Birthday;
import ezvcard.property.Email;
import ezvcard.property.FormattedName;
import ezvcard.property.Impp;
import ezvcard.property.Logo;
import ezvcard.property.Nickname;
import ezvcard.property.Note;
import ezvcard.property.Organization;
@ -42,12 +43,15 @@ import ezvcard.property.Photo;
import ezvcard.property.RawProperty;
import ezvcard.property.Revision;
import ezvcard.property.Role;
import ezvcard.property.Sound;
import ezvcard.property.Source;
import ezvcard.property.StructuredName;
import ezvcard.property.Telephone;
import ezvcard.property.Title;
import ezvcard.property.Uid;
import ezvcard.property.Url;
@ToString(callSuper = true)
public class Contact extends Resource {
private final static String TAG = "davdroid.Contact";
@ -68,7 +72,9 @@ public class Contact extends Resource {
PHONE_TYPE_ASSISTANT = TelephoneType.get("X-ASSISTANT"),
PHONE_TYPE_MMS = TelephoneType.get("X-MMS");
@Getter @Setter boolean starred;
@Getter @Setter private String unknownProperties;
@Getter @Setter private boolean starred;
@Getter @Setter private String displayName, nickName;
@Getter @Setter private String prefix, givenName, middleName, familyName, suffix;
@ -98,17 +104,17 @@ public class Contact extends Resource {
public Contact(long localID, String resourceName, String eTag) {
super(localID, resourceName, eTag);
}
@Override
public void generateUID() {
uid = UUID.randomUUID().toString();
}
@Override
public void generateName() {
public void initialize() {
generateUID();
name = uid + ".vcf";
}
protected void generateUID() {
uid = UUID.randomUUID().toString();
}
/* VCard methods */
@ -119,20 +125,37 @@ public class Contact extends Resource {
if (vcard == null)
return;
// now work through all supported properties
// supported properties are removed from the VCard after parsing
// so that only unknown properties are left and can be stored separately
// UID
Uid uid = vcard.getUid();
if (uid == null) {
if (uid != null) {
this.uid = uid.getValue();
vcard.removeProperties(Uid.class);
} else {
Log.w(TAG, "Received VCard without UID, generating new one");
uid = new Uid(UUID.randomUUID().toString());
generateUID();
}
this.uid = uid.getValue();
// X-DAVDROID-STARRED
RawProperty starred = vcard.getExtendedProperty(PROPERTY_STARRED);
this.starred = starred != null && starred.getValue().equals("1");
if (starred != null && starred.getValue() != null) {
this.starred = starred.getValue().equals("1");
vcard.removeExtendedProperty(PROPERTY_STARRED);
} else
this.starred = false;
// FN
FormattedName fn = vcard.getFormattedName();
if (fn != null)
if (fn != null) {
displayName = fn.getValue();
vcard.removeProperties(FormattedName.class);
} else
Log.w(TAG, "Received invalid VCard without FN (formatted name) property");
// N
StructuredName n = vcard.getStructuredName();
if (n != null) {
prefix = StringUtils.join(n.getPrefixes(), " ");
@ -140,77 +163,141 @@ public class Contact extends Resource {
middleName = StringUtils.join(n.getAdditional(), " ");
familyName = n.getFamily();
suffix = StringUtils.join(n.getSuffixes(), " ");
vcard.removeProperties(StructuredName.class);
}
// phonetic names
RawProperty
phoneticFirstName = vcard.getExtendedProperty(PROPERTY_PHONETIC_FIRST_NAME),
phoneticMiddleName = vcard.getExtendedProperty(PROPERTY_PHONETIC_MIDDLE_NAME),
phoneticLastName = vcard.getExtendedProperty(PROPERTY_PHONETIC_LAST_NAME);
if (phoneticFirstName != null)
if (phoneticFirstName != null) {
phoneticGivenName = phoneticFirstName.getValue();
if (phoneticMiddleName != null)
vcard.removeExtendedProperty(PROPERTY_PHONETIC_FIRST_NAME);
}
if (phoneticMiddleName != null) {
this.phoneticMiddleName = phoneticMiddleName.getValue();
if (phoneticLastName != null)
vcard.removeExtendedProperty(PROPERTY_PHONETIC_MIDDLE_NAME);
}
if (phoneticLastName != null) {
phoneticFamilyName = phoneticLastName.getValue();
vcard.removeExtendedProperty(PROPERTY_PHONETIC_LAST_NAME);
}
// TEL
phoneNumbers = vcard.getTelephoneNumbers();
vcard.removeProperties(Telephone.class);
// EMAIL
emails = vcard.getEmails();
vcard.removeProperties(Email.class);
// PHOTO
for (Photo photo : vcard.getPhotos()) {
this.photo = photo.getData();
vcard.removeProperties(Photo.class);
break;
}
// ORG
organization = vcard.getOrganization();
vcard.removeProperties(Organization.class);
// TITLE
for (Title title : vcard.getTitles()) {
jobTitle = title.getValue();
vcard.removeProperties(Title.class);
break;
}
// ROLE
for (Role role : vcard.getRoles()) {
this.jobDescription = role.getValue();
vcard.removeProperties(Role.class);
break;
}
// IMPP
impps = vcard.getImpps();
vcard.removeProperties(Impp.class);
// NICKNAME
Nickname nicknames = vcard.getNickname();
if (nicknames != null && nicknames.getValues() != null)
nickName = StringUtils.join(nicknames.getValues(), ", ");
if (nicknames != null) {
if (nicknames.getValues() != null)
nickName = StringUtils.join(nicknames.getValues(), ", ");
vcard.removeProperties(Nickname.class);
}
// NOTE
List<String> notes = new LinkedList<String>();
for (Note note : vcard.getNotes())
notes.add(note.getValue());
if (!notes.isEmpty())
note = StringUtils.join(notes, "\n---\n");
vcard.removeProperties(Note.class);
// ADR
addresses = vcard.getAddresses();
vcard.removeProperties(Address.class);
// URL
for (Url url : vcard.getUrls())
URLs.add(url.getValue());
vcard.removeProperties(Url.class);
// BDAY
birthDay = vcard.getBirthday();
vcard.removeProperties(Birthday.class);
// ANNIVERSARY
anniversary = vcard.getAnniversary();
vcard.removeProperties(Anniversary.class);
// get X-SIP and import as IMPP
// X-SIP
for (RawProperty sip : vcard.getExtendedProperties(PROPERTY_SIP))
impps.add(new Impp("sip", sip.getValue()));
vcard.removeExtendedProperty(PROPERTY_SIP);
// remove binary properties because of potential OutOfMemory / TransactionTooLarge exceptions
vcard.removeProperties(Logo.class);
vcard.removeProperties(Sound.class);
// remove properties that don't apply anymore
vcard.removeProperties(Revision.class);
vcard.removeProperties(Source.class);
// store all remaining properties into unknownProperties
if (!vcard.getProperties().isEmpty() || !vcard.getExtendedProperties().isEmpty())
unknownProperties = vcard.write();
else
unknownProperties = null;
}
@Override
public ByteArrayOutputStream toEntity() throws IOException {
VCard vcard = new VCard();
vcard.setProdId("DAVdroid/" + Constants.APP_VERSION + " (ez-vcard/" + Ezvcard.VERSION + ")");
VCard vcard = null;
try {
if (unknownProperties != null)
vcard = Ezvcard.parse(unknownProperties).first();
} catch (Exception e) {
Log.w(TAG, "Couldn't parse original property set, beginning from scratch");
}
if (vcard == null)
vcard = new VCard();
if (uid != null)
vcard.setUid(new Uid(uid));
else
Log.wtf(TAG, "Generating VCard without UID");
if (starred)
vcard.setExtendedProperty(PROPERTY_STARRED, "1");
if (displayName != null)
vcard.setFormattedName(displayName);
else if (organization != null && organization.getValues() != null && organization.getValues().get(0) != null)
vcard.setFormattedName(organization.getValues().get(0));
else
Log.w(TAG, "No FN (formatted name) available to generate VCard");
// N
if (familyName != null || middleName != null || givenName != null) {
StructuredName n = new StructuredName();
if (prefix != null)
@ -227,6 +314,7 @@ public class Contact extends Resource {
vcard.setStructuredName(n);
}
// phonetic names
if (phoneticGivenName != null)
vcard.addExtendedProperty(PROPERTY_PHONETIC_FIRST_NAME, phoneticGivenName);
if (phoneticMiddleName != null)
@ -234,42 +322,55 @@ public class Contact extends Resource {
if (phoneticFamilyName != null)
vcard.addExtendedProperty(PROPERTY_PHONETIC_LAST_NAME, phoneticFamilyName);
// TEL
for (Telephone phoneNumber : phoneNumbers)
vcard.addTelephoneNumber(phoneNumber);
// EMAIL
for (Email email : emails)
vcard.addEmail(email);
// ORG, TITLE, ROLE
if (organization != null)
vcard.addOrganization(organization);
vcard.setOrganization(organization);
if (jobTitle != null)
vcard.addTitle(jobTitle);
if (jobDescription != null)
vcard.addRole(jobDescription);
// IMPP
for (Impp impp : impps)
vcard.addImpp(impp);
if (nickName != null && !nickName.isEmpty())
// NICKNAME
if (!StringUtils.isBlank(nickName))
vcard.setNickname(nickName);
if (note != null && !note.isEmpty())
// NOTE
if (!StringUtils.isBlank(note))
vcard.addNote(note);
// ADR
for (Address address : addresses)
vcard.addAddress(address);
// URL
for (String url : URLs)
vcard.addUrl(url);
// ANNIVERSARY
if (anniversary != null)
vcard.setAnniversary(anniversary);
// BDAY
if (birthDay != null)
vcard.setBirthday(birthDay);
// PHOTO
if (photo != null)
vcard.addPhoto(new Photo(photo, ImageType.JPEG));
// PRODID, REV
vcard.setProdId("DAVdroid/" + Constants.APP_VERSION + " (ez-vcard/" + Ezvcard.VERSION + ")");
vcard.setRevision(Revision.now());
ByteArrayOutputStream os = new ByteArrayOutputStream();

@ -109,14 +109,14 @@ public class Event extends Resource {
@Override
public void generateUID() {
UidGenerator generator = new UidGenerator(new SimpleHostInfo(DavSyncAdapter.getAndroidID()), String.valueOf(android.os.Process.myPid()));
uid = generator.generateUid().getValue();
public void initialize() {
generateUID();
name = uid.replace("@", "_") + ".ics";
}
@Override
public void generateName() {
name = uid.replace("@", "_") + ".ics";
protected void generateUID() {
UidGenerator generator = new UidGenerator(new SimpleHostInfo(DavSyncAdapter.getAndroidID()), String.valueOf(android.os.Process.myPid()));
uid = generator.generateUid().getValue();
}

@ -20,10 +20,6 @@ import java.util.Set;
import lombok.Cleanup;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang.WordUtils;
import android.accounts.Account;
import android.content.ContentProviderClient;
import android.content.ContentProviderOperation;
@ -49,7 +45,11 @@ import android.provider.ContactsContract.CommonDataKinds.Website;
import android.provider.ContactsContract.Data;
import android.provider.ContactsContract.RawContacts;
import android.util.Log;
import at.bitfire.davdroid.syncadapter.AccountSettings;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang.WordUtils;
import ezvcard.parameter.AddressType;
import ezvcard.parameter.EmailType;
import ezvcard.parameter.ImppType;
@ -61,10 +61,15 @@ import ezvcard.property.DateOrTimeProperty;
import ezvcard.property.Impp;
import ezvcard.property.Telephone;
import at.bitfire.davdroid.syncadapter.AccountSettings;
public class LocalAddressBook extends LocalCollection<Contact> {
private final static String TAG = "davdroid.LocalAddressBook";
protected final static String COLUMN_UNKNOWN_PROPERTIES = RawContacts.SYNC3;
protected AccountSettings accountSettings;
@ -146,10 +151,11 @@ public class LocalAddressBook extends LocalCollection<Contact> {
try {
@Cleanup Cursor cursor = providerClient.query(ContentUris.withAppendedId(entriesURI(), c.getLocalID()),
new String[] { entryColumnUID(), RawContacts.STARRED }, null, null, null);
new String[] { entryColumnUID(), COLUMN_UNKNOWN_PROPERTIES, RawContacts.STARRED }, null, null, null);
if (cursor != null && cursor.moveToNext()) {
c.setUid(cursor.getString(0));
c.setStarred(cursor.getInt(1) != 0);
c.setUnknownProperties(cursor.getString(1));
c.setStarred(cursor.getInt(2) != 0);
} else
throw new RecordNotFoundException();
@ -177,6 +183,7 @@ public class LocalAddressBook extends LocalCollection<Contact> {
/* 6 */ StructuredName.PHONETIC_GIVEN_NAME, StructuredName.PHONETIC_MIDDLE_NAME, StructuredName.PHONETIC_FAMILY_NAME
}, StructuredName.RAW_CONTACT_ID + "=? AND " + Data.MIMETYPE + "=?",
new String[] { String.valueOf(c.getLocalID()), StructuredName.CONTENT_ITEM_TYPE }, null);
if (cursor != null && cursor.moveToNext()) {
c.setDisplayName(cursor.getString(0));
@ -512,6 +519,7 @@ public class LocalAddressBook extends LocalCollection<Contact> {
.withValue(entryColumnRemoteName(), contact.getName())
.withValue(entryColumnUID(), contact.getUid())
.withValue(entryColumnETag(), contact.getETag())
.withValue(COLUMN_UNKNOWN_PROPERTIES, contact.getUnknownProperties())
.withValue(RawContacts.STARRED, contact.isStarred());
}
@ -519,8 +527,8 @@ public class LocalAddressBook extends LocalCollection<Contact> {
@Override
protected void addDataRows(Resource resource, long localID, int backrefIdx) {
Contact contact = (Contact)resource;
pendingOperations.add(buildStructuredName(newDataInsertBuilder(localID, backrefIdx), contact).build());
queueOperation(buildStructuredName(newDataInsertBuilder(localID, backrefIdx), contact));
for (Telephone number : contact.getPhoneNumbers())
queueOperation(buildPhoneNumber(newDataInsertBuilder(localID, backrefIdx), number));
@ -531,9 +539,7 @@ public class LocalAddressBook extends LocalCollection<Contact> {
if (contact.getPhoto() != null)
queueOperation(buildPhoto(newDataInsertBuilder(localID, backrefIdx), contact.getPhoto()));
if (contact.getOrganization() != null || contact.getJobTitle() != null || contact.getJobDescription() != null)
queueOperation(buildOrganization(newDataInsertBuilder(localID, backrefIdx),
contact.getOrganization(), contact.getJobTitle(), contact.getJobDescription()));
queueOperation(buildOrganization(newDataInsertBuilder(localID, backrefIdx), contact));
for (Impp impp : contact.getImpps())
queueOperation(buildIMPP(newDataInsertBuilder(localID, backrefIdx), impp));
@ -560,7 +566,7 @@ public class LocalAddressBook extends LocalCollection<Contact> {
// TODO relations
// SIP addresses built by buildIMPP
// SIP addresses are built by buildIMPP
}
@Override
@ -695,9 +701,12 @@ public class LocalAddressBook extends LocalCollection<Contact> {
.withValue(Photo.PHOTO, photo);
}
protected Builder buildOrganization(Builder builder, ezvcard.property.Organization organization, String jobTitle, String jobDescription) {
protected Builder buildOrganization(Builder builder, Contact contact) {
if (contact.getOrganization() == null && contact.getJobTitle() == null && contact.getJobDescription() == null)
return null;
ezvcard.property.Organization organization = contact.getOrganization();
String company = null, department = null;
if (organization != null) {
Iterator<String> org = organization.getValues().iterator();
if (org.hasNext())
@ -710,8 +719,8 @@ public class LocalAddressBook extends LocalCollection<Contact> {
.withValue(Data.MIMETYPE, Organization.CONTENT_ITEM_TYPE)
.withValue(Organization.COMPANY, company)
.withValue(Organization.DEPARTMENT, department)
.withValue(Organization.TITLE, jobTitle)
.withValue(Organization.JOB_DESCRIPTION, jobDescription);
.withValue(Organization.TITLE, contact.getJobTitle())
.withValue(Organization.JOB_DESCRIPTION, contact.getJobDescription());
}
protected Builder buildIMPP(Builder builder, Impp impp) {

@ -40,7 +40,7 @@ public abstract class LocalCollection<T extends Resource> {
abstract protected String entryColumnAccountType();
abstract protected String entryColumnAccountName();
abstract protected String entryColumnParentID();
abstract protected String entryColumnID();
abstract protected String entryColumnRemoteName();
@ -85,8 +85,7 @@ public abstract class LocalCollection<T extends Resource> {
// new record: generate UID + remote file name so that we can upload
T resource = findById(id, false);
resource.generateUID();
resource.generateName();
resource.initialize();
// write generated UID + remote file name into database
ContentValues values = new ContentValues(2);
values.put(entryColumnUID(), resource.getUid());

@ -23,7 +23,7 @@ public abstract class Resource {
@Getter protected String name, ETag;
@Getter @Setter protected String uid;
@Getter protected long localID;
public Resource(String name, String ETag) {
this.name = name;
@ -36,8 +36,7 @@ public abstract class Resource {
}
// sets UID and resource name (= remote file name)
public abstract void generateUID();
public abstract void generateName();
public abstract void initialize();
public abstract void parseEntity(InputStream entity) throws IOException, InvalidResourceException;
public abstract ByteArrayOutputStream toEntity() throws IOException;

@ -14,7 +14,6 @@ import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.Map;
import lombok.Synchronized;
import android.accounts.Account;
import android.app.Service;
import android.content.ContentProviderClient;
@ -32,13 +31,13 @@ public class CalendarsSyncAdapterService extends Service {
private static SyncAdapter syncAdapter;
@Override @Synchronized
@Override
public void onCreate() {
if (syncAdapter == null)
syncAdapter = new SyncAdapter(getApplicationContext());
}
@Override @Synchronized
@Override
public void onDestroy() {
syncAdapter.close();
syncAdapter = null;

@ -14,7 +14,6 @@ import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.Map;
import lombok.Synchronized;
import android.accounts.Account;
import android.app.Service;
import android.content.ContentProviderClient;
@ -29,9 +28,9 @@ import at.bitfire.davdroid.resource.RemoteCollection;
public class ContactsSyncAdapterService extends Service {
private static ContactsSyncAdapter syncAdapter;
@Override @Synchronized
@Override
public void onCreate() {
if (syncAdapter == null)
syncAdapter = new ContactsSyncAdapter(getApplicationContext());
@ -55,7 +54,6 @@ public class ContactsSyncAdapterService extends Service {
private ContactsSyncAdapter(Context context) {
super(context);
Log.i(TAG, "httpClient = " + httpClient);
}
@Override
@ -71,7 +69,6 @@ public class ContactsSyncAdapterService extends Service {
try {
LocalCollection<?> database = new LocalAddressBook(account, provider, settings);
Log.i(TAG, "httpClient 2 = " + httpClient);
RemoteCollection<?> dav = new CardDavAddressBook(httpClient, addressBookURL, userName, password, preemptive);
Map<LocalCollection<?>, RemoteCollection<?>> map = new HashMap<LocalCollection<?>, RemoteCollection<?>>();

@ -57,7 +57,8 @@ public abstract class DavSyncAdapter extends AbstractThreadedSyncAdapter impleme
httpClient = DavHttpClient.create();
}
@Override public void close() {
@Override
public void close() {
// apparently may be called from a GUI thread
new AsyncTask<Void, Void, Void>() {
@Override
@ -104,7 +105,7 @@ public abstract class DavSyncAdapter extends AbstractThreadedSyncAdapter impleme
Log.e(TAG, "Hard HTTP error " + ex.getCode(), ex);
syncResult.stats.numParseExceptions++;
} else {
Log.w(TAG, "Soft HTTP error" + ex.getCode(), ex);
Log.w(TAG, "Soft HTTP error " + ex.getCode() + " (Android will try again later)", ex);
syncResult.stats.numIoExceptions++;
}
@ -113,7 +114,7 @@ public abstract class DavSyncAdapter extends AbstractThreadedSyncAdapter impleme
Log.e(TAG, "Local storage (content provider) exception", ex);
} catch (IOException ex) {
syncResult.stats.numIoExceptions++;
Log.e(TAG, "I/O error", ex);
Log.e(TAG, "I/O error (Android will try again later)", ex);
}
}
}

@ -36,7 +36,7 @@ public class DavHttpClient {
defaultRqConfig = RequestConfig.copy(RequestConfig.DEFAULT)
.setConnectTimeout(20*1000)
.setSocketTimeout(20*1000)
.setStaleConnectionCheckEnabled(false)
.setStaleConnectionCheckEnabled(true)
.build();
// enable logging
@ -56,6 +56,7 @@ public class DavHttpClient {
.useSystemProperties()
.setConnectionManager(connectionManager)
.setDefaultRequestConfig(defaultRqConfig)
.setRetryHandler(DavHttpRequestRetryHandler.INSTANCE)
.setUserAgent("DAVdroid/" + Constants.APP_VERSION)
.disableCookieManagement()
.build();

@ -0,0 +1,28 @@
package at.bitfire.davdroid.webdav;
import java.util.Locale;
import org.apache.commons.lang.ArrayUtils;
import ch.boye.httpclientandroidlib.HttpRequest;
import ch.boye.httpclientandroidlib.impl.client.DefaultHttpRequestRetryHandler;
public class DavHttpRequestRetryHandler extends DefaultHttpRequestRetryHandler {
final static DavHttpRequestRetryHandler INSTANCE = new DavHttpRequestRetryHandler();
// see http://www.iana.org/assignments/http-methods/http-methods.xhtml
private final static String idempotentMethods[] = {
"DELETE", "GET", "HEAD", "MKCALENDAR", "MKCOL", "OPTIONS", "PROPFIND", "PROPPATCH",
"PUT", "REPORT", "SEARCH", "TRACE"
};
public DavHttpRequestRetryHandler() {
super(/* retry count */ 3, /* retry already sent requests? */ false);
}
@Override
protected boolean handleAsIdempotent(final HttpRequest request) {
final String method = request.getRequestLine().getMethod().toUpperCase(Locale.ROOT);
return ArrayUtils.contains(idempotentMethods, method);
}
}

@ -14,9 +14,7 @@ import lombok.Cleanup;
import org.apache.commons.io.IOUtils;
import android.content.res.AssetManager;
import android.os.Build;
import android.test.InstrumentationTestCase;
import android.util.Log;
import at.bitfire.davdroid.webdav.DavException;
import at.bitfire.davdroid.webdav.DavHttpClient;
import at.bitfire.davdroid.webdav.DavMultiget;
@ -31,8 +29,6 @@ import ch.boye.httpclientandroidlib.impl.client.CloseableHttpClient;
// tests require running robohydra!
public class WebDavResourceTest extends InstrumentationTestCase {
private static final String TAG = "davdroidTest.WebDavResourceTest";
static final String ROBOHYDRA_BASE = "http://10.0.0.11:3000/";
static byte[] SAMPLE_CONTENT = new byte[] { 1, 2, 3, 4, 5 };

Loading…
Cancel
Save