From 7f4b4855a04f70a2e7a5b34c7ae8497943797257 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Sat, 10 Oct 2015 23:30:38 +0200 Subject: [PATCH] First implementation of CardDAV sync with dav4android and vcard4android * try to get rid of Apache Commons --- .gitmodules | 3 + app/build.gradle | 9 +- .../davdroid/resource/ContactTest.java | 109 -- .../syncadapter/DavResourceFinderTest.java | 3 - .../davdroid/resource/CardDavAddressBook.java | 42 - .../at/bitfire/davdroid/resource/Contact.java | 463 ------- .../davdroid/resource/LocalAddressBook.java | 1079 +---------------- .../davdroid/resource/LocalContact.java | 46 + .../ContactsSyncAdapterService.java | 98 +- settings.gradle | 1 + vcard4android | 1 + 11 files changed, 127 insertions(+), 1727 deletions(-) delete mode 100644 app/src/androidTest/java/at/bitfire/davdroid/resource/ContactTest.java delete mode 100644 app/src/main/java/at/bitfire/davdroid/resource/CardDavAddressBook.java delete mode 100644 app/src/main/java/at/bitfire/davdroid/resource/Contact.java create mode 100644 app/src/main/java/at/bitfire/davdroid/resource/LocalContact.java create mode 160000 vcard4android diff --git a/.gitmodules b/.gitmodules index 8add43a4..60d7b388 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "dav4android"] path = dav4android url = git@gitlab.com:bitfireAT/dav4android.git +[submodule "vcard4android"] + path = vcard4android + url = git@gitlab.com:bitfireAT/vcard4android.git diff --git a/app/build.gradle b/app/build.gradle index db2034fc..a85e3418 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -56,14 +56,6 @@ dependencies { exclude group: 'org.codehaus.groovy', module: 'groovy-all' } compile('org.slf4j:slf4j-android:1.7.12') // slf4j is used by ical4j - // ez-vcard for parsing/generating VCards - compile('com.googlecode.ez-vcard:ez-vcard:0.9.6') { - // hCard functionality not needed - exclude group: 'org.jsoup', module: 'jsoup' - exclude group: 'org.freemarker', module: 'freemarker' - // jCard functionality not needed - exclude group: 'com.fasterxml.jackson.core', module: 'jackson-core' - } // dnsjava for querying SRV/TXT records compile 'dnsjava:dnsjava:2.1.7' // HttpClient 4.3, Android flavour for WebDAV operations @@ -76,4 +68,5 @@ dependencies { } compile project(':dav4android') + compile project(':vcard4android') } diff --git a/app/src/androidTest/java/at/bitfire/davdroid/resource/ContactTest.java b/app/src/androidTest/java/at/bitfire/davdroid/resource/ContactTest.java deleted file mode 100644 index 992822e4..00000000 --- a/app/src/androidTest/java/at/bitfire/davdroid/resource/ContactTest.java +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright © 2013 – 2015 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.resource; - -import android.content.res.AssetManager; -import android.test.InstrumentationTestCase; - -import org.apache.commons.io.IOUtils; -import org.apache.commons.lang3.CharEncoding; - -import java.io.IOException; -import java.io.InputStream; -import java.net.URI; -import java.net.URISyntaxException; -import java.nio.charset.Charset; -import java.util.Arrays; - -import at.bitfire.davdroid.webdav.DavException; -import at.bitfire.davdroid.webdav.HttpException; -import ezvcard.VCardVersion; -import ezvcard.property.Email; -import ezvcard.property.Telephone; -import lombok.Cleanup; - -public class ContactTest extends InstrumentationTestCase { - AssetManager assetMgr; - - public void setUp() throws IOException, InvalidResourceException { - assetMgr = getInstrumentation().getContext().getResources().getAssets(); - } - - public void testGenerateDifferentVersions() throws Exception { - Contact c = new Contact("test.vcf", null); - - // should generate VCard 3.0 by default - assertEquals("text/vcard; charset=utf-8", c.getContentType().toString().toLowerCase()); - assertTrue(new String(c.toEntity().toByteArray()).contains("VERSION:3.0")); - - // now let's generate VCard 4.0 - c.vCardVersion = VCardVersion.V4_0; - assertEquals("text/vcard; version=4.0", c.getContentType().toString()); - assertTrue(new String(c.toEntity().toByteArray()).contains("VERSION:4.0")); - } - - public void testReferenceVCard3() throws IOException, InvalidResourceException { - Contact c = parseVCF("reference-vcard3.vcf", Charset.forName(CharEncoding.UTF_8)); - - assertEquals("Gümp", c.familyName); - assertEquals("Förrest", c.givenName); - assertEquals("Förrest Gümp", c.displayName); - assertEquals("Bubba Gump Shrimpß Co.", c.organization.getValues().get(0)); - assertEquals("Shrimp Man", c.jobTitle); - - Telephone phone1 = c.getPhoneNumbers().get(0); - assertEquals("(111) 555-1212", phone1.getText()); - assertEquals("WORK", phone1.getParameters("TYPE").get(0)); - assertEquals("VOICE", phone1.getParameters("TYPE").get(1)); - - Telephone phone2 = c.getPhoneNumbers().get(1); - assertEquals("(404) 555-1212", phone2.getText()); - assertEquals("HOME", phone2.getParameters("TYPE").get(0)); - assertEquals("VOICE", phone2.getParameters("TYPE").get(1)); - - Email email = c.getEmails().get(0); - assertEquals("forrestgump@example.com", email.getValue()); - assertEquals("PREF", email.getParameters("TYPE").get(0)); - assertEquals("INTERNET", email.getParameters("TYPE").get(1)); - - @Cleanup InputStream photoStream = assetMgr.open("davdroid-logo-192.png", AssetManager.ACCESS_STREAMING); - byte[] expectedPhoto = IOUtils.toByteArray(photoStream); - assertTrue(Arrays.equals(c.photo, expectedPhoto)); - } - - public void testParseInvalidUnknownProperties() throws IOException { - Contact c = parseVCF("invalid-unknown-properties.vcf"); - assertEquals("VCard with invalid unknown properties", c.displayName); - assertNull(c.unknownProperties); - } - - public void testParseLatin1() throws IOException { - Contact c = parseVCF("latin1.vcf", Charset.forName(CharEncoding.ISO_8859_1)); - assertEquals("Özkan Äuçek", c.displayName); - assertEquals("Özkan", c.givenName); - assertEquals("Äuçek", c.familyName); - assertNull(c.unknownProperties); - } - - - protected Contact parseVCF(String fname, Charset charset) throws IOException { - @Cleanup InputStream in = assetMgr.open(fname, AssetManager.ACCESS_STREAMING); - Contact c = new Contact(fname, null); - c.parseEntity(in, charset, new Resource.AssetDownloader() { - @Override - public byte[] download(URI uri) throws URISyntaxException, IOException, HttpException, DavException { - return IOUtils.toByteArray(uri); - } - }); - return c; - } - - protected Contact parseVCF(String fname) throws IOException { - return parseVCF(fname, null); - } -} diff --git a/app/src/androidTest/java/at/bitfire/davdroid/syncadapter/DavResourceFinderTest.java b/app/src/androidTest/java/at/bitfire/davdroid/syncadapter/DavResourceFinderTest.java index 72b42a75..a471d191 100644 --- a/app/src/androidTest/java/at/bitfire/davdroid/syncadapter/DavResourceFinderTest.java +++ b/app/src/androidTest/java/at/bitfire/davdroid/syncadapter/DavResourceFinderTest.java @@ -30,7 +30,6 @@ public class DavResourceFinderTest extends InstrumentationTestCase { @Override protected void tearDown() throws IOException { - finder.close(); } @@ -39,7 +38,6 @@ public class DavResourceFinderTest extends InstrumentationTestCase { finder.findResources(info); /*** CardDAV ***/ - assertTrue(info.isCardDAV()); List collections = info.getAddressBooks(); // two address books assertEquals(2, collections.size()); @@ -52,7 +50,6 @@ public class DavResourceFinderTest extends InstrumentationTestCase { assertEquals("Absolute URI VCard Book", collection.getDescription()); /*** CalDAV ***/ - assertTrue(info.isCalDAV()); collections = info.getCalendars(); assertEquals(2, collections.size()); diff --git a/app/src/main/java/at/bitfire/davdroid/resource/CardDavAddressBook.java b/app/src/main/java/at/bitfire/davdroid/resource/CardDavAddressBook.java deleted file mode 100644 index f485cde9..00000000 --- a/app/src/main/java/at/bitfire/davdroid/resource/CardDavAddressBook.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright © 2013 – 2015 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.resource; - -import org.apache.http.impl.client.CloseableHttpClient; - -import java.net.URISyntaxException; - -import at.bitfire.davdroid.syncadapter.AccountSettings; -import at.bitfire.davdroid.webdav.DavMultiget; -import ezvcard.VCardVersion; - -public class CardDavAddressBook extends WebDavCollection { - AccountSettings accountSettings; - - @Override - protected String memberAcceptedMimeTypes() { - return "text/vcard;q=0.8, text/vcard;version=4.0"; - } - - @Override - protected DavMultiget.Type multiGetType() { - return accountSettings.getAddressBookVCardVersion() == VCardVersion.V4_0 ? - DavMultiget.Type.ADDRESS_BOOK_V4 : DavMultiget.Type.ADDRESS_BOOK; - } - - @Override - protected Contact newResourceSkeleton(String name, String ETag) { - return new Contact(name, ETag); - } - - - public CardDavAddressBook(AccountSettings settings, CloseableHttpClient httpClient, String baseURL, String user, String password, boolean preemptiveAuth) throws URISyntaxException { - super(httpClient, baseURL, user, password, preemptiveAuth); - accountSettings = settings; - } -} diff --git a/app/src/main/java/at/bitfire/davdroid/resource/Contact.java b/app/src/main/java/at/bitfire/davdroid/resource/Contact.java deleted file mode 100644 index e9aa592a..00000000 --- a/app/src/main/java/at/bitfire/davdroid/resource/Contact.java +++ /dev/null @@ -1,463 +0,0 @@ -/* - * Copyright © 2013 – 2015 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.resource; - -import android.util.Log; - -import org.apache.commons.codec.CharEncoding; -import org.apache.commons.lang3.StringUtils; -import org.apache.http.entity.ContentType; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.net.URI; -import java.nio.charset.Charset; -import java.util.LinkedList; -import java.util.List; -import java.util.UUID; - -import at.bitfire.davdroid.Constants; -import ezvcard.Ezvcard; -import ezvcard.VCard; -import ezvcard.VCardVersion; -import ezvcard.ValidationWarnings; -import ezvcard.parameter.EmailType; -import ezvcard.parameter.ImageType; -import ezvcard.parameter.RelatedType; -import ezvcard.parameter.TelephoneType; -import ezvcard.property.Address; -import ezvcard.property.Anniversary; -import ezvcard.property.Birthday; -import ezvcard.property.Categories; -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; -import ezvcard.property.Photo; -import ezvcard.property.ProductId; -import ezvcard.property.RawProperty; -import ezvcard.property.Related; -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; -import lombok.Cleanup; -import lombok.Getter; -import lombok.ToString; - - -/** - * Represents a contact. Locally, this is a Contact in the Android - * device; remote, this is a VCard. - */ -@ToString(callSuper = true) -public class Contact extends Resource { - private final static String TAG = "davdroid.Contact"; - - protected VCardVersion vCardVersion = VCardVersion.V3_0; - - public static final ContentType - MIME_VCARD3 = ContentType.create("text/vcard", CharEncoding.UTF_8), - MIME_VCARD4 = ContentType.parse("text/vcard; version=4.0"); - - public static final String - PROPERTY_STARRED = "X-DAVDROID-STARRED", - PROPERTY_PHONETIC_FIRST_NAME = "X-PHONETIC-FIRST-NAME", - PROPERTY_PHONETIC_MIDDLE_NAME = "X-PHONETIC-MIDDLE-NAME", - PROPERTY_PHONETIC_LAST_NAME = "X-PHONETIC-LAST-NAME", - PROPERTY_SIP = "X-SIP"; - - public static final EmailType EMAIL_TYPE_MOBILE = EmailType.get("x-mobile"); - - public static final TelephoneType - PHONE_TYPE_CALLBACK = TelephoneType.get("x-callback"), - PHONE_TYPE_COMPANY_MAIN = TelephoneType.get("x-company_main"), - PHONE_TYPE_RADIO = TelephoneType.get("x-radio"), - PHONE_TYPE_ASSISTANT = TelephoneType.get("X-assistant"), - PHONE_TYPE_MMS = TelephoneType.get("x-mms"); - public static final RelatedType - RELATED_TYPE_BROTHER = RelatedType.get("brother"), - RELATED_TYPE_FATHER = RelatedType.get("father"), - RELATED_TYPE_MANAGER = RelatedType.get("manager"), - RELATED_TYPE_MOTHER = RelatedType.get("mother"), - RELATED_TYPE_REFERRED_BY = RelatedType.get("referred-by"), - RELATED_TYPE_SISTER = RelatedType.get("sister"); - - protected String unknownProperties; - protected boolean starred; - - protected String displayName, nickName; - protected String prefix, givenName, middleName, familyName, suffix; - protected String phoneticGivenName, phoneticMiddleName, phoneticFamilyName; - protected String note; - protected Organization organization; - protected String jobTitle, jobDescription; - - protected byte[] photo; - - protected Anniversary anniversary; - protected Birthday birthDay; - - // lists must not be set to null (because they're iterated using "for"), so only getters are exposed - @Getter private List phoneNumbers = new LinkedList<>(); - @Getter private List emails = new LinkedList<>(); - @Getter private List impps = new LinkedList<>(); - @Getter private List
addresses = new LinkedList<>(); - @Getter private List categories = new LinkedList<>(); - @Getter private List URLs = new LinkedList<>(); - @Getter private List relations = new LinkedList<>(); - - /* instance methods */ - - Contact(String name, String ETag) { - super(name, ETag); - } - - Contact(long localID, String resourceName, String eTag) { - super(localID, resourceName, eTag); - } - - - @Override - public void initialize() { - generateUID(); - name = uid + ".vcf"; - } - - protected void generateUID() { - uid = UUID.randomUUID().toString(); - } - - - /* VCard methods */ - - @SuppressWarnings("LoopStatementThatDoesntLoop") - @Override - public void parseEntity(InputStream is, Charset charset, AssetDownloader downloader) throws IOException { - final VCard vcard; - if (charset != null) { - @Cleanup InputStreamReader reader = new InputStreamReader(is, charset); - vcard = Ezvcard.parse(reader).first(); - } else - vcard = Ezvcard.parse(is).first(); - 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) { - this.uid = uid.getValue(); - vcard.removeProperties(Uid.class); - } else { - Log.w(TAG, "Received VCard without UID, generating new one"); - generateUID(); - } - - // X-DAVDROID-STARRED - RawProperty starred = vcard.getExtendedProperty(PROPERTY_STARRED); - 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) { - 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(), " "); - givenName = n.getGiven(); - 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) { - phoneticGivenName = phoneticFirstName.getValue(); - vcard.removeExtendedProperty(PROPERTY_PHONETIC_FIRST_NAME); - } - if (phoneticMiddleName != null) { - this.phoneticMiddleName = phoneticMiddleName.getValue(); - 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(); - if (this.photo == null && photo.getUrl() != null) - try { - URI uri = new URI(photo.getUrl()); - Log.i(TAG, "Downloading contact photo from " + uri); - this.photo = downloader.download(uri); - } catch(Exception e) { - Log.w(TAG, "Couldn't fetch contact photo", e); - } - 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) { - if (nicknames.getValues() != null) - nickName = StringUtils.join(nicknames.getValues(), ", "); - vcard.removeProperties(Nickname.class); - } - - // NOTE - List notes = new LinkedList<>(); - 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); - - // CATEGORY - Categories categories = vcard.getCategories(); - if (categories != null) - this.categories = categories.getValues(); - vcard.removeProperties(Categories.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); - - // RELATED - for (Related related : vcard.getRelations()) { - String text = related.getText(); - if (!StringUtils.isNotEmpty(text)) { - // process only free-form relations with text - relations.add(related); - vcard.removeProperty(related); - } - } - - // 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(ProductId.class); - vcard.removeProperties(Revision.class); - vcard.removeProperties(Source.class); - // store all remaining properties into unknownProperties - if (!vcard.getProperties().isEmpty() || !vcard.getExtendedProperties().isEmpty()) - try { - unknownProperties = vcard.write(); - } catch(Exception e) { - Log.w(TAG, "Couldn't store unknown properties (maybe illegal syntax), dropping them"); - } - } - - - @Override - public ContentType getContentType() { - return (vCardVersion == VCardVersion.V4_0) ? MIME_VCARD4 : MIME_VCARD3; - } - - @Override - public ByteArrayOutputStream toEntity() throws IOException { - 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 (prefix != null || familyName != null || middleName != null || givenName != null || suffix != null) { - StructuredName n = new StructuredName(); - if (prefix != null) - for (String p : StringUtils.split(prefix)) - n.addPrefix(p); - n.setGiven(givenName); - if (middleName != null) - for (String middle : StringUtils.split(middleName)) - n.addAdditional(middle); - n.setFamily(familyName); - if (suffix != null) - for (String s : StringUtils.split(suffix)) - n.addSuffix(s); - vcard.setStructuredName(n); - } - - // phonetic names - if (phoneticGivenName != null) - vcard.addExtendedProperty(PROPERTY_PHONETIC_FIRST_NAME, phoneticGivenName); - if (phoneticMiddleName != null) - vcard.addExtendedProperty(PROPERTY_PHONETIC_MIDDLE_NAME, phoneticMiddleName); - 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.setOrganization(organization); - if (jobTitle != null) - vcard.addTitle(jobTitle); - if (jobDescription != null) - vcard.addRole(jobDescription); - - // IMPP - for (Impp impp : impps) - vcard.addImpp(impp); - - // NICKNAME - if (!StringUtils.isBlank(nickName)) - vcard.setNickname(nickName); - - // NOTE - if (!StringUtils.isBlank(note)) - vcard.addNote(note); - - // ADR - for (Address address : addresses) - vcard.addAddress(address); - - // CATEGORY - if (!categories.isEmpty()) - vcard.setCategories(categories.toArray(new String[categories.size()])); - - // 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)); - - // REL - for (Related related : relations) - vcard.addRelated(related); - - // PRODID, REV - vcard.setProductId("DAVdroid/" + Constants.APP_VERSION + " (ez-vcard/" + Ezvcard.VERSION + ")"); - vcard.setRevision(Revision.now()); - - // validate and print warnings - ValidationWarnings warnings = vcard.validate(vCardVersion); - if (!warnings.isEmpty()) - Log.w(TAG, "Created potentially invalid VCard:\n" + warnings); - - ByteArrayOutputStream os = new ByteArrayOutputStream(); - Ezvcard - .write(vcard) - .version(vCardVersion) - .versionStrict(false) - .prodId(false) // we provide our own PRODID - .go(os); - return os; - } -} diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalAddressBook.java b/app/src/main/java/at/bitfire/davdroid/resource/LocalAddressBook.java index ba657c2b..47be63a1 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalAddressBook.java +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalAddressBook.java @@ -9,1076 +9,25 @@ package at.bitfire.davdroid.resource; import android.accounts.Account; import android.content.ContentProviderClient; -import android.content.ContentProviderOperation; -import android.content.ContentProviderOperation.Builder; -import android.content.ContentUris; -import android.content.ContentValues; -import android.content.Entity; -import android.content.EntityIterator; -import android.content.res.AssetFileDescriptor; -import android.database.Cursor; -import android.net.Uri; -import android.os.RemoteException; import android.provider.ContactsContract; -import android.provider.ContactsContract.CommonDataKinds; -import android.provider.ContactsContract.CommonDataKinds.Email; -import android.provider.ContactsContract.CommonDataKinds.GroupMembership; -import android.provider.ContactsContract.CommonDataKinds.Im; -import android.provider.ContactsContract.CommonDataKinds.Nickname; -import android.provider.ContactsContract.CommonDataKinds.Note; -import android.provider.ContactsContract.CommonDataKinds.Organization; -import android.provider.ContactsContract.CommonDataKinds.Phone; -import android.provider.ContactsContract.CommonDataKinds.Photo; -import android.provider.ContactsContract.CommonDataKinds.Relation; -import android.provider.ContactsContract.CommonDataKinds.SipAddress; -import android.provider.ContactsContract.CommonDataKinds.StructuredName; -import android.provider.ContactsContract.CommonDataKinds.StructuredPostal; -import android.provider.ContactsContract.CommonDataKinds.Website; -import android.provider.ContactsContract.Data; -import android.provider.ContactsContract.Groups; -import android.provider.ContactsContract.RawContacts; -import android.util.Log; -import org.apache.commons.io.IOUtils; -import org.apache.commons.lang3.StringUtils; - -import java.io.IOException; -import java.io.InputStream; -import java.text.ParseException; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Iterator; -import java.util.LinkedList; -import java.util.List; -import java.util.Locale; -import java.util.Set; - -import at.bitfire.davdroid.syncadapter.AccountSettings; -import at.bitfire.davdroid.webdav.WebDavResource; -import ezvcard.VCardVersion; -import ezvcard.parameter.AddressType; -import ezvcard.parameter.EmailType; -import ezvcard.parameter.ImppType; -import ezvcard.parameter.RelatedType; -import ezvcard.parameter.TelephoneType; -import ezvcard.property.Address; -import ezvcard.property.Anniversary; -import ezvcard.property.Birthday; -import ezvcard.property.DateOrTimeProperty; -import ezvcard.property.Impp; -import ezvcard.property.Related; -import ezvcard.property.Telephone; -import lombok.Cleanup; +import at.bitfire.vcard4android.AndroidAddressBook; +import at.bitfire.vcard4android.AndroidContact; +import at.bitfire.vcard4android.AndroidContactFactory; +import at.bitfire.vcard4android.AndroidGroupFactory; +import at.bitfire.vcard4android.Contact; +import at.bitfire.vcard4android.ContactsStorageException; -public class LocalAddressBook extends LocalCollection { - private final static String TAG = "davdroid.resource"; - - protected final static String COLUMN_UNKNOWN_PROPERTIES = RawContacts.SYNC3; +public class LocalAddressBook extends AndroidAddressBook { - final protected AccountSettings accountSettings; + public LocalAddressBook(Account account, ContentProviderClient provider) { + super(account, provider, AndroidGroupFactory.INSTANCE, LocalContact.Factory.INSTANCE); + } - - /* database fields */ - - @Override - protected Uri entriesURI() { - return syncAdapterURI(RawContacts.CONTENT_URI); - } - - protected String entryColumnAccountType() { return RawContacts.ACCOUNT_TYPE; } - protected String entryColumnAccountName() { return RawContacts.ACCOUNT_NAME; } - - protected String entryColumnParentID() { return null; /* maybe use RawContacts.DATA_SET some day? */ } - protected String entryColumnID() { return RawContacts._ID; } - protected String entryColumnRemoteName() { return RawContacts.SOURCE_ID; } - protected String entryColumnETag() { return RawContacts.SYNC2; } - - protected String entryColumnDirty() { return RawContacts.DIRTY; } - protected String entryColumnDeleted() { return RawContacts.DELETED; } - - protected String entryColumnUID() { return RawContacts.SYNC1; } - - - - public LocalAddressBook(Account account, ContentProviderClient providerClient, AccountSettings accountSettings) { - super(account, providerClient); - this.accountSettings = accountSettings; - } - - - /* collection operations */ - - @Override - public long getId() { - return -1; - } - - @Override - public String getCTag() { - return accountSettings.getAddressBookCTag(); - } - - @Override - public void setCTag(String cTag) { - accountSettings.setAddressBookCTag(cTag); - } - - @Override - public void updateMetaData(WebDavResource.Properties properties) - { - final VCardVersion vCardVersion = properties.getSupportedVCardVersion(); - accountSettings.setAddressBookVCardVersion(vCardVersion != null ? vCardVersion : VCardVersion.V3_0); - } - - - /* create/update/delete */ - - public Contact newResource(long localID, String resourceName, String eTag) { - Contact c = new Contact(localID, resourceName, eTag); - c.vCardVersion = accountSettings.getAddressBookVCardVersion(); - return c; - } - - @Override - public int commit() throws LocalStorageException { - int affected = super.commit(); - - // update group details for groups we have just created - Uri groupsUri = syncAdapterURI(Groups.CONTENT_URI); - try { - // newly created groups don't have a TITLE - @Cleanup Cursor cursor = providerClient.query(groupsUri, - new String[]{Groups.SOURCE_ID}, - Groups.TITLE + " IS NULL", null, null - ); - while (cursor != null && cursor.moveToNext()) { - // found group, set TITLE to SOURCE_ID and other details - String sourceID = cursor.getString(0); - pendingOperations.add(ContentProviderOperation.newUpdate(groupsUri) - .withSelection(Groups.SOURCE_ID + "=?", new String[] { sourceID }) - .withValue(Groups.TITLE, sourceID) - .withValue(Groups.GROUP_VISIBLE, 1) - .build()); - affected += super.commit(); - } - } catch (RemoteException e) { - throw new LocalStorageException("Couldn't update group names", e); - } - - return affected; - } - - - /* methods for populating the data object from the content provider */ - - @Override - public void populate(Resource res) throws LocalStorageException { - Contact c = (Contact)res; - - try { - @Cleanup EntityIterator iter = ContactsContract.RawContacts.newEntityIterator(providerClient.query( - syncAdapterURI(ContactsContract.RawContactsEntity.CONTENT_URI), - null, RawContacts._ID + "=" + c.getLocalID(), - null, null)); - - if (iter.hasNext()) { - Entity e = iter.next(); - - ContentValues values = e.getEntityValues(); - c.setUid(values.getAsString(entryColumnUID())); - c.unknownProperties = values.getAsString(COLUMN_UNKNOWN_PROPERTIES); - c.starred = values.getAsInteger(RawContacts.STARRED) != 0; - - List subValues = e.getSubValues(); - for (Entity.NamedContentValues subValue : subValues) { - values = subValue.values; - String mimeType = values.getAsString(ContactsContract.RawContactsEntity.MIMETYPE); - switch (mimeType) { - case StructuredName.CONTENT_ITEM_TYPE: - populateStructuredName(c, values); - break; - case Phone.CONTENT_ITEM_TYPE: - populatePhoneNumber(c, values); - break; - case Email.CONTENT_ITEM_TYPE: - populateEmailAddress(c, values); - break; - case Photo.CONTENT_ITEM_TYPE: - populatePhoto(c, values); - break; - case Organization.CONTENT_ITEM_TYPE: - populateOrganization(c, values); - break; - case Im.CONTENT_ITEM_TYPE: - populateIMPP(c, values); - break; - case Nickname.CONTENT_ITEM_TYPE: - populateNickname(c, values); - break; - case Note.CONTENT_ITEM_TYPE: - populateNote(c, values); - break; - case StructuredPostal.CONTENT_ITEM_TYPE: - populatePostalAddress(c, values); - break; - case GroupMembership.CONTENT_ITEM_TYPE: - populateGroupMembership(c, values); - break; - case Website.CONTENT_ITEM_TYPE: - populateURL(c, values); - break; - case CommonDataKinds.Event.CONTENT_ITEM_TYPE: - populateEvent(c, values); - break; - case Relation.CONTENT_ITEM_TYPE: - populateRelation(c, values); - break; - case SipAddress.CONTENT_ITEM_TYPE: - populateSipAddress(c, values); - break; - } - } - } else - throw new RecordNotFoundException(); - } catch(RemoteException ex) { - throw new LocalStorageException(ex); - } - } - - private void populateStructuredName(Contact c, ContentValues row) { - c.displayName = row.getAsString(StructuredName.DISPLAY_NAME); - - c.prefix = row.getAsString(StructuredName.PREFIX); - c.givenName = row.getAsString(StructuredName.GIVEN_NAME); - c.middleName = row.getAsString(StructuredName.MIDDLE_NAME); - c.familyName = row.getAsString(StructuredName.FAMILY_NAME); - c.suffix = row.getAsString(StructuredName.SUFFIX); - - c.phoneticGivenName = row.getAsString(StructuredName.PHONETIC_GIVEN_NAME); - c.phoneticMiddleName = row.getAsString(StructuredName.PHONETIC_MIDDLE_NAME); - c.phoneticFamilyName = row.getAsString(StructuredName.PHONETIC_FAMILY_NAME); - } - - protected void populatePhoneNumber(Contact c, ContentValues row) { - ezvcard.property.Telephone number = new ezvcard.property.Telephone(row.getAsString(Phone.NUMBER)); - switch (row.getAsInteger(Phone.TYPE)) { - case Phone.TYPE_HOME: - number.addType(TelephoneType.HOME); - break; - case Phone.TYPE_MOBILE: - number.addType(TelephoneType.CELL); - break; - case Phone.TYPE_WORK: - number.addType(TelephoneType.WORK); - break; - case Phone.TYPE_FAX_WORK: - number.addType(TelephoneType.FAX); - number.addType(TelephoneType.WORK); - break; - case Phone.TYPE_FAX_HOME: - number.addType(TelephoneType.FAX); - number.addType(TelephoneType.HOME); - break; - case Phone.TYPE_PAGER: - number.addType(TelephoneType.PAGER); - break; - case Phone.TYPE_CALLBACK: - number.addType(Contact.PHONE_TYPE_CALLBACK); - break; - case Phone.TYPE_CAR: - number.addType(TelephoneType.CAR); - break; - case Phone.TYPE_COMPANY_MAIN: - number.addType(Contact.PHONE_TYPE_COMPANY_MAIN); - break; - case Phone.TYPE_ISDN: - number.addType(TelephoneType.ISDN); - break; - case Phone.TYPE_MAIN: - number.addType(TelephoneType.PREF); - break; - case Phone.TYPE_OTHER_FAX: - number.addType(TelephoneType.FAX); - break; - case Phone.TYPE_RADIO: - number.addType(Contact.PHONE_TYPE_RADIO); - break; - case Phone.TYPE_TELEX: - number.addType(TelephoneType.TEXTPHONE); - break; - case Phone.TYPE_TTY_TDD: - number.addType(TelephoneType.TEXT); - break; - case Phone.TYPE_WORK_MOBILE: - number.addType(TelephoneType.CELL); - number.addType(TelephoneType.WORK); - break; - case Phone.TYPE_WORK_PAGER: - number.addType(TelephoneType.PAGER); - number.addType(TelephoneType.WORK); - break; - case Phone.TYPE_ASSISTANT: - number.addType(Contact.PHONE_TYPE_ASSISTANT); - break; - case Phone.TYPE_MMS: - number.addType(Contact.PHONE_TYPE_MMS); - break; - case Phone.TYPE_CUSTOM: - String customType = row.getAsString(Phone.LABEL); - if (StringUtils.isNotEmpty(customType)) - number.addType(TelephoneType.get(labelToXName(customType))); - } - if (row.getAsInteger(Phone.IS_PRIMARY) != 0) - number.addType(TelephoneType.PREF); - c.getPhoneNumbers().add(number); - } - - protected void populateEmailAddress(Contact c, ContentValues row) { - ezvcard.property.Email email = new ezvcard.property.Email(row.getAsString(Email.ADDRESS)); - if (row.containsKey(Email.TYPE)) - switch (row.getAsInteger(Email.TYPE)) { - case Email.TYPE_HOME: - email.addType(EmailType.HOME); - break; - case Email.TYPE_WORK: - email.addType(EmailType.WORK); - break; - case Email.TYPE_MOBILE: - email.addType(Contact.EMAIL_TYPE_MOBILE); - break; - case Email.TYPE_CUSTOM: - String customType = row.getAsString(Email.LABEL); - if (StringUtils.isNotEmpty(customType)) - email.addType(EmailType.get(labelToXName(customType))); - } - if (row.getAsInteger(Email.IS_PRIMARY) != 0) - email.addType(EmailType.PREF); - c.getEmails().add(email); - } - - protected void populatePhoto(Contact c, ContentValues row) throws RemoteException { - if (row.containsKey(Photo.PHOTO_FILE_ID)) { - Uri photoUri = Uri.withAppendedPath( - ContentUris.withAppendedId(RawContacts.CONTENT_URI, c.getLocalID()), - RawContacts.DisplayPhoto.CONTENT_DIRECTORY); - try { - @Cleanup AssetFileDescriptor fd = providerClient.openAssetFile(photoUri, "r"); - @Cleanup InputStream is = fd.createInputStream(); - c.photo = IOUtils.toByteArray(is); - } catch(IOException ex) { - Log.w(TAG, "Couldn't read high-res contact photo", ex); - } - } else - c.photo = row.getAsByteArray(Photo.PHOTO); - } - - protected void populateOrganization(Contact c, ContentValues row) { - String company = row.getAsString(Organization.COMPANY), - department = row.getAsString(Organization.DEPARTMENT), - title = row.getAsString(Organization.TITLE), - role = row.getAsString(Organization.JOB_DESCRIPTION); - - if (StringUtils.isNotEmpty(company) || StringUtils.isNotEmpty(department)) { - ezvcard.property.Organization org = new ezvcard.property.Organization(); - if (StringUtils.isNotEmpty(company)) - org.addValue(company); - if (StringUtils.isNotEmpty(department)) - org.addValue(department); - c.organization = org; - } - - if (StringUtils.isNotEmpty(title)) - c.jobTitle = title; - if (StringUtils.isNotEmpty(role)) - c.jobDescription = role; - } - - protected void populateIMPP(Contact c, ContentValues row) { - String handle = row.getAsString(Im.DATA); - - Impp impp = null; - switch (row.getAsInteger(Im.PROTOCOL)) { - case Im.PROTOCOL_AIM: - impp = Impp.aim(handle); - break; - case Im.PROTOCOL_MSN: - impp = Impp.msn(handle); - break; - case Im.PROTOCOL_YAHOO: - impp = Impp.yahoo(handle); - break; - case Im.PROTOCOL_SKYPE: - impp = Impp.skype(handle); - break; - case Im.PROTOCOL_QQ: - impp = new Impp("qq", handle); - break; - case Im.PROTOCOL_GOOGLE_TALK: - impp = new Impp("google-talk", handle); - break; - case Im.PROTOCOL_ICQ: - impp = Impp.icq(handle); - break; - case Im.PROTOCOL_JABBER: - impp = Impp.xmpp(handle); - break; - case Im.PROTOCOL_NETMEETING: - impp = new Impp("netmeeting", handle); - break; - case Im.PROTOCOL_CUSTOM: - impp = new Impp(row.getAsString(Im.CUSTOM_PROTOCOL), handle); - } - - if (impp != null) { - if (row.containsKey(Im.TYPE)) - switch (row.getAsInteger(Im.TYPE)) { - case Im.TYPE_HOME: - impp.addType(ImppType.HOME); - break; - case Im.TYPE_WORK: - impp.addType(ImppType.WORK); - break; - case Im.TYPE_CUSTOM: - String customType = row.getAsString(Im.LABEL); - if (StringUtils.isNotEmpty(customType)) - impp.addType(ImppType.get(labelToXName(customType))); - } - - c.getImpps().add(impp); - } - } - - protected void populateNickname(Contact c, ContentValues row) { - // TYPE (maiden name, short name, …) and LABEL are not processed here because Contacts app doesn't support it - c.nickName = row.getAsString(Nickname.NAME); - } - - protected void populateNote(Contact c, ContentValues row) { - c.note = row.getAsString(Note.NOTE); - } - - protected void populatePostalAddress(Contact c, ContentValues row) { - Address address = new Address(); - address.setLabel(row.getAsString(StructuredPostal.FORMATTED_ADDRESS)); - if (row.containsKey(StructuredPostal.TYPE)) - switch (row.getAsInteger(StructuredPostal.TYPE)) { - case StructuredPostal.TYPE_HOME: - address.addType(AddressType.HOME); - break; - case StructuredPostal.TYPE_WORK: - address.addType(AddressType.WORK); - break; - case StructuredPostal.TYPE_CUSTOM: - String customType = row.getAsString(StructuredPostal.LABEL); - if (StringUtils.isNotEmpty(customType)) - address.addType(AddressType.get(labelToXName(customType))); - break; - } - address.setStreetAddress(row.getAsString(StructuredPostal.STREET)); - address.setPoBox(row.getAsString(StructuredPostal.POBOX)); - address.setExtendedAddress(row.getAsString(StructuredPostal.NEIGHBORHOOD)); - address.setLocality(row.getAsString(StructuredPostal.CITY)); - address.setRegion(row.getAsString(StructuredPostal.REGION)); - address.setPostalCode(row.getAsString(StructuredPostal.POSTCODE)); - address.setCountry(row.getAsString(StructuredPostal.COUNTRY)); - c.getAddresses().add(address); - } - - protected void populateGroupMembership(Contact c, ContentValues row) throws RemoteException { - List categories = c.getCategories(); - - long rowID = row.getAsLong(GroupMembership.GROUP_ROW_ID); - String sourceID = row.getAsString(GroupMembership.GROUP_SOURCE_ID); - - // either a row ID or a source ID must be available - String where, whereArg; - if (sourceID == null) { - where = Groups._ID + "=?"; - whereArg = String.valueOf(rowID); - } else { - where = Groups.SOURCE_ID + "=?"; - whereArg = sourceID; - } - where += " AND " + Groups.DELETED + "=0"; // ignore deleted groups - Log.d(TAG, "Populating group from " + where + " " + whereArg); - - // fetch group - @Cleanup Cursor cursorGroups = providerClient.query(Groups.CONTENT_URI, - new String[] { Groups.TITLE }, - where, new String[] { whereArg }, null - ); - if (cursorGroups != null && cursorGroups.moveToNext()) { - String title = cursorGroups.getString(0); - - if (sourceID == null) { // Group wasn't created by DAVdroid - // SOURCE_ID IS NULL <=> _ID IS NOT NULL - Log.d(TAG, "Setting SOURCE_ID of non-DAVdroid group to title: " + title); - - ContentValues v = new ContentValues(1); - v.put(Groups.SOURCE_ID, title); - v.put(Groups.GROUP_IS_READ_ONLY, 0); - v.put(Groups.GROUP_VISIBLE, 1); - providerClient.update(syncAdapterURI(Groups.CONTENT_URI), v, Groups._ID + "=?", new String[] { String.valueOf(rowID) }); - - sourceID = title; - } - - // add group to CATEGORIES - if (sourceID != null) - categories.add(sourceID); - } else - Log.d(TAG, "Group not found (maybe deleted)"); - } - - protected void populateURL(Contact c, ContentValues row) { - c.getURLs().add(row.getAsString(Website.URL)); - } - - protected void populateEvent(Contact c, ContentValues row) { - SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd", Locale.US); - try { - Date date = formatter.parse(row.getAsString(CommonDataKinds.Event.START_DATE)); - switch (row.getAsInteger(CommonDataKinds.Event.TYPE)) { - case CommonDataKinds.Event.TYPE_ANNIVERSARY: - c.anniversary = new Anniversary(date); - break; - case CommonDataKinds.Event.TYPE_BIRTHDAY: - c.birthDay = new Birthday(date); - break; - } - } catch (ParseException e) { - Log.w(TAG, "Couldn't parse local birthday/anniversary date", e); - } - } - - protected void populateRelation(Contact c, ContentValues row) { - String name = row.getAsString(Relation.NAME); - - // don't process empty relations - if (StringUtils.isEmpty(name)) - return; - - // find relation by name or create new one - Related related = null; - for (Related rel : c.getRelations()) { - if (name.equals(rel.getText())) { - related = rel; - break; - } - } - if (related == null) { - related = new Related(); - c.getRelations().add(related); - } - - Set types = related.getTypes(); - switch (row.getAsInteger(Relation.TYPE)) { - case Relation.TYPE_ASSISTANT: - types.add(RelatedType.AGENT); - break; - case Relation.TYPE_BROTHER: - types.add(RelatedType.SIBLING); - types.add(Contact.RELATED_TYPE_BROTHER); - break; - case Relation.TYPE_CHILD: - types.add(RelatedType.CHILD); - break; - case Relation.TYPE_DOMESTIC_PARTNER: - types.add(RelatedType.CO_RESIDENT); - break; - case Relation.TYPE_FATHER: - types.add(Contact.RELATED_TYPE_FATHER); - break; - case Relation.TYPE_FRIEND: - types.add(RelatedType.FRIEND); - break; - case Relation.TYPE_MANAGER: - types.add(Contact.RELATED_TYPE_MANAGER); - break; - case Relation.TYPE_MOTHER: - types.add(Contact.RELATED_TYPE_MOTHER); - break; - case Relation.TYPE_PARENT: - types.add(RelatedType.PARENT); - break; - case Relation.TYPE_PARTNER: - types.add(RelatedType.SWEETHEART); - break; - case Relation.TYPE_REFERRED_BY: - types.add(Contact.RELATED_TYPE_REFERRED_BY); - case Relation.TYPE_RELATIVE: - types.add(RelatedType.KIN); - break; - case Relation.TYPE_SISTER: - types.add(RelatedType.SIBLING); - types.add(Contact.RELATED_TYPE_SISTER); - break; - case Relation.TYPE_SPOUSE: - types.add(RelatedType.SPOUSE); - case Relation.TYPE_CUSTOM: - String customType = row.getAsString(Relation.LABEL); - if (StringUtils.isNotEmpty(customType)) - types.add(RelatedType.get(customType)); - } - } - - protected void populateSipAddress(Contact c, ContentValues row) { - try { - Impp impp = new Impp("sip:" + row.getAsString(SipAddress.SIP_ADDRESS)); - if (row.containsKey(SipAddress.TYPE)) - switch (row.getAsInteger(SipAddress.TYPE)) { - case SipAddress.TYPE_HOME: - impp.addType(ImppType.HOME); - break; - case SipAddress.TYPE_WORK: - impp.addType(ImppType.WORK); - break; - case SipAddress.TYPE_CUSTOM: - String customType = row.getAsString(SipAddress.LABEL); - if (StringUtils.isNotEmpty(customType)) - impp.addType(ImppType.get(labelToXName(customType))); - } - c.getImpps().add(impp); - } catch(IllegalArgumentException e) { - Log.e(TAG, "Illegal SIP URI", e); - } - } - - - /* content builder methods */ - - @Override - protected Builder buildEntry(Builder builder, Resource resource, boolean update) { - Contact contact = (Contact)resource; - - if (!update) - builder .withValue(RawContacts.ACCOUNT_NAME, account.name) - .withValue(RawContacts.ACCOUNT_TYPE, account.type); - - return builder - .withValue(entryColumnRemoteName(), contact.getName()) - .withValue(entryColumnUID(), contact.getUid()) - .withValue(entryColumnETag(), contact.getETag()) - .withValue(COLUMN_UNKNOWN_PROPERTIES, contact.unknownProperties) - .withValue(RawContacts.STARRED, contact.starred ? 1 : 0); - } - - - @Override - protected void addDataRows(Resource resource, long localID, int backrefIdx) { - Contact contact = (Contact)resource; - - queueOperation(buildStructuredName(newDataInsertBuilder(localID, backrefIdx), contact)); - - for (Telephone number : contact.getPhoneNumbers()) - queueOperation(buildPhoneNumber(newDataInsertBuilder(localID, backrefIdx), number)); - - for (ezvcard.property.Email email : contact.getEmails()) - queueOperation(buildEmail(newDataInsertBuilder(localID, backrefIdx), email)); - - if (contact.photo != null) - queueOperation(buildPhoto(newDataInsertBuilder(localID, backrefIdx), contact.photo)); - - queueOperation(buildOrganization(newDataInsertBuilder(localID, backrefIdx), contact)); - - for (Impp impp : contact.getImpps()) - queueOperation(buildIMPP(newDataInsertBuilder(localID, backrefIdx), impp)); - - if (contact.nickName != null) - queueOperation(buildNickName(newDataInsertBuilder(localID, backrefIdx), contact.nickName)); - - if (contact.note != null) - queueOperation(buildNote(newDataInsertBuilder(localID, backrefIdx), contact.note)); - - for (Address address : contact.getAddresses()) - queueOperation(buildAddress(newDataInsertBuilder(localID, backrefIdx), address)); - - for (String category : contact.getCategories()) - queueOperation(buildGroupMembership(newDataInsertBuilder(localID, backrefIdx), category)); - - for (String url : contact.getURLs()) - queueOperation(buildURL(newDataInsertBuilder(localID, backrefIdx), url)); - - if (contact.anniversary != null) - queueOperation(buildEvent(newDataInsertBuilder(localID, backrefIdx), contact.anniversary, CommonDataKinds.Event.TYPE_ANNIVERSARY)); - if (contact.birthDay != null) - queueOperation(buildEvent(newDataInsertBuilder(localID, backrefIdx), contact.birthDay, CommonDataKinds.Event.TYPE_BIRTHDAY)); - - for (Related related : contact.getRelations()) - for (RelatedType type : related.getTypes()) - queueOperation(buildRelated(newDataInsertBuilder(localID, backrefIdx), type, related.getText())); - - // SIP addresses are built by buildIMPP - } - - @Override - protected void removeDataRows(Resource resource) { - pendingOperations.add(ContentProviderOperation.newDelete(dataURI()) - .withSelection(Data.RAW_CONTACT_ID + "=?", - new String[] { String.valueOf(resource.getLocalID()) }).build()); - } - - - protected Builder buildStructuredName(Builder builder, Contact contact) { - return builder - .withValue(Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE) - .withValue(StructuredName.PREFIX, contact.prefix) - .withValue(StructuredName.DISPLAY_NAME, contact.displayName) - .withValue(StructuredName.GIVEN_NAME, contact.givenName) - .withValue(StructuredName.MIDDLE_NAME, contact.middleName) - .withValue(StructuredName.FAMILY_NAME, contact.familyName) - .withValue(StructuredName.SUFFIX, contact.suffix) - .withValue(StructuredName.PHONETIC_GIVEN_NAME, contact.phoneticGivenName) - .withValue(StructuredName.PHONETIC_MIDDLE_NAME, contact.phoneticMiddleName) - .withValue(StructuredName.PHONETIC_FAMILY_NAME, contact.phoneticFamilyName); - } - - protected Builder buildPhoneNumber(Builder builder, Telephone number) { - int typeCode = Phone.TYPE_OTHER; - String typeLabel = null; - - Set types = number.getTypes(); - - // preferred number? - boolean is_primary = false; - if (types.contains(TelephoneType.PREF)) { - is_primary = true; - types.remove(TelephoneType.PREF); - } - - // 1 Android type <-> 2 VCard types: fax, cell, pager - if (types.contains(TelephoneType.FAX)) { - if (types.contains(TelephoneType.HOME)) - typeCode = Phone.TYPE_FAX_HOME; - else if (types.contains(TelephoneType.WORK)) - typeCode = Phone.TYPE_FAX_WORK; - else - typeCode = Phone.TYPE_OTHER_FAX; - } else if (types.contains(TelephoneType.CELL)) { - if (types.contains(TelephoneType.WORK)) - typeCode = Phone.TYPE_WORK_MOBILE; - else - typeCode = Phone.TYPE_MOBILE; - } else if (types.contains(TelephoneType.PAGER)) { - if (types.contains(TelephoneType.WORK)) - typeCode = Phone.TYPE_WORK_PAGER; - else - typeCode = Phone.TYPE_PAGER; - // types with 1:1 translation - } else if (types.contains(TelephoneType.HOME)) { - typeCode = Phone.TYPE_HOME; - } else if (types.contains(TelephoneType.WORK)) { - typeCode = Phone.TYPE_WORK; - } else if (types.contains(Contact.PHONE_TYPE_CALLBACK)) { - typeCode = Phone.TYPE_CALLBACK; - } else if (types.contains(TelephoneType.CAR)) { - typeCode = Phone.TYPE_CAR; - } else if (types.contains(Contact.PHONE_TYPE_COMPANY_MAIN)) { - typeCode = Phone.TYPE_COMPANY_MAIN; - } else if (types.contains(TelephoneType.ISDN)) { - typeCode = Phone.TYPE_ISDN; - } else if (types.contains(Contact.PHONE_TYPE_RADIO)) { - typeCode = Phone.TYPE_RADIO; - } else if (types.contains(TelephoneType.TEXTPHONE)) { - typeCode = Phone.TYPE_TELEX; - } else if (types.contains(TelephoneType.TEXT)) { - typeCode = Phone.TYPE_TTY_TDD; - } else if (types.contains(Contact.PHONE_TYPE_ASSISTANT)) { - typeCode = Phone.TYPE_ASSISTANT; - } else if (types.contains(Contact.PHONE_TYPE_MMS)) { - typeCode = Phone.TYPE_MMS; - } else if (!types.isEmpty()) { - TelephoneType type = types.iterator().next(); - typeCode = Phone.TYPE_CUSTOM; - typeLabel = xNameToLabel(type.getValue()); - } - - builder .withValue(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE) - .withValue(Phone.NUMBER, number.getText()) - .withValue(Phone.TYPE, typeCode) - .withValue(Phone.IS_PRIMARY, is_primary ? 1 : 0) - .withValue(Phone.IS_SUPER_PRIMARY, is_primary ? 1 : 0); - if (typeLabel != null) - builder.withValue(Phone.LABEL, typeLabel); - return builder; - } - - protected Builder buildEmail(Builder builder, ezvcard.property.Email email) { - int typeCode = 0; - String typeLabel = null; - boolean is_primary = false; - - for (EmailType type : email.getTypes()) - if (type == EmailType.PREF) - is_primary = true; - else if (type == EmailType.HOME) - typeCode = Email.TYPE_HOME; - else if (type == EmailType.WORK) - typeCode = Email.TYPE_WORK; - else if (type == Contact.EMAIL_TYPE_MOBILE) - typeCode = Email.TYPE_MOBILE; - if (typeCode == 0) { - if (email.getTypes().isEmpty()) - typeCode = Email.TYPE_OTHER; - else { - typeCode = Email.TYPE_CUSTOM; - typeLabel = xNameToLabel(email.getTypes().iterator().next().getValue()); - } - } - - builder .withValue(Data.MIMETYPE, Email.CONTENT_ITEM_TYPE) - .withValue(Email.ADDRESS, email.getValue()) - .withValue(Email.TYPE, typeCode) - .withValue(Email.IS_PRIMARY, is_primary ? 1 : 0) - .withValue(Phone.IS_SUPER_PRIMARY, is_primary ? 1 : 0); - if (typeLabel != null) - builder.withValue(Email.LABEL, typeLabel); - return builder; - } - - protected Builder buildPhoto(Builder builder, byte[] photo) { - return builder - .withValue(Data.MIMETYPE, Photo.CONTENT_ITEM_TYPE) - .withValue(Photo.PHOTO, photo); - } - - protected Builder buildOrganization(Builder builder, Contact contact) { - if (contact.organization == null && contact.jobTitle == null && contact.jobDescription == null) - return null; - - ezvcard.property.Organization organization = contact.organization; - String company = null, department = null; - if (organization != null) { - Iterator org = organization.getValues().iterator(); - if (org.hasNext()) - company = org.next(); - if (org.hasNext()) - department = org.next(); - } - - return builder - .withValue(Data.MIMETYPE, Organization.CONTENT_ITEM_TYPE) - .withValue(Organization.COMPANY, company) - .withValue(Organization.DEPARTMENT, department) - .withValue(Organization.TITLE, contact.jobTitle) - .withValue(Organization.JOB_DESCRIPTION, contact.jobDescription); - } - - protected Builder buildIMPP(Builder builder, Impp impp) { - int typeCode = 0; - String typeLabel = null; - for (ImppType type : impp.getTypes()) - if (type == ImppType.HOME) - typeCode = Im.TYPE_HOME; - else if (type == ImppType.WORK || type == ImppType.BUSINESS) - typeCode = Im.TYPE_WORK; - if (typeCode == 0) - if (impp.getTypes().isEmpty()) - typeCode = Im.TYPE_OTHER; - else { - typeCode = Im.TYPE_CUSTOM; - typeLabel = xNameToLabel(impp.getTypes().iterator().next().getValue()); - } - - int protocolCode = 0; - String protocolLabel = null; - - String protocol = impp.getProtocol(); - if (protocol == null) { - Log.w(TAG, "Ignoring IMPP address without protocol"); - return null; - } - - // SIP addresses are IMPP entries in the VCard but locally stored in SipAddress rather than Im - boolean sipAddress = false; - - if (impp.isAim()) - protocolCode = Im.PROTOCOL_AIM; - else if (impp.isMsn()) - protocolCode = Im.PROTOCOL_MSN; - else if (impp.isYahoo()) - protocolCode = Im.PROTOCOL_YAHOO; - else if (impp.isSkype()) - protocolCode = Im.PROTOCOL_SKYPE; - else if (protocol.equalsIgnoreCase("qq")) - protocolCode = Im.PROTOCOL_QQ; - else if (protocol.equalsIgnoreCase("google-talk")) - protocolCode = Im.PROTOCOL_GOOGLE_TALK; - else if (impp.isIcq()) - protocolCode = Im.PROTOCOL_ICQ; - else if (impp.isXmpp() || protocol.equalsIgnoreCase("jabber")) - protocolCode = Im.PROTOCOL_JABBER; - else if (protocol.equalsIgnoreCase("netmeeting")) - protocolCode = Im.PROTOCOL_NETMEETING; - else if (protocol.equalsIgnoreCase("sip")) - sipAddress = true; - else { - protocolCode = Im.PROTOCOL_CUSTOM; - protocolLabel = protocol; - } - - if (sipAddress) - // save as SIP address - builder .withValue(Data.MIMETYPE, SipAddress.CONTENT_ITEM_TYPE) - .withValue(Im.DATA, impp.getHandle()) - .withValue(Im.TYPE, typeCode); - else { - // save as IM address - builder .withValue(Data.MIMETYPE, Im.CONTENT_ITEM_TYPE) - .withValue(Im.DATA, impp.getHandle()) - .withValue(Im.TYPE, typeCode) - .withValue(Im.PROTOCOL, protocolCode); - if (protocolLabel != null) - builder.withValue(Im.CUSTOM_PROTOCOL, protocolLabel); - } - if (typeLabel != null) - builder.withValue(Im.LABEL, typeLabel); - return builder; - } - - protected Builder buildNickName(Builder builder, String nickName) { - return builder - .withValue(Data.MIMETYPE, Nickname.CONTENT_ITEM_TYPE) - .withValue(Nickname.NAME, nickName); - } - - protected Builder buildNote(Builder builder, String note) { - return builder - .withValue(Data.MIMETYPE, Note.CONTENT_ITEM_TYPE) - .withValue(Note.NOTE, note); - } - - protected Builder buildAddress(Builder builder, Address address) { - /* street po.box (extended) - * region - * postal code city - * country - */ - String formattedAddress = address.getLabel(); - if (StringUtils.isEmpty(formattedAddress)) { - String lineStreet = StringUtils.join(new String[] { address.getStreetAddress(), address.getPoBox(), address.getExtendedAddress() }, " "), - lineLocality = StringUtils.join(new String[] { address.getPostalCode(), address.getLocality() }, " "); - - List lines = new LinkedList<>(); - if (StringUtils.isNotBlank(lineStreet)) - lines.add(lineStreet); - if (address.getRegion() != null && !address.getRegion().isEmpty()) - lines.add(address.getRegion()); - if (StringUtils.isNotBlank(lineLocality)) - lines.add(lineLocality); - - formattedAddress = StringUtils.join(lines, "\n"); - } - - int typeCode = 0; - String typeLabel = null; - for (AddressType type : address.getTypes()) - if (type == AddressType.HOME) - typeCode = StructuredPostal.TYPE_HOME; - else if (type == AddressType.WORK) - typeCode = StructuredPostal.TYPE_WORK; - if (typeCode == 0) - if (address.getTypes().isEmpty()) - typeCode = StructuredPostal.TYPE_OTHER; - else { - typeCode = StructuredPostal.TYPE_CUSTOM; - typeLabel = xNameToLabel(address.getTypes().iterator().next().getValue()); - } - - builder .withValue(Data.MIMETYPE, StructuredPostal.CONTENT_ITEM_TYPE) - .withValue(StructuredPostal.FORMATTED_ADDRESS, formattedAddress) - .withValue(StructuredPostal.TYPE, typeCode) - .withValue(StructuredPostal.STREET, address.getStreetAddress()) - .withValue(StructuredPostal.POBOX, address.getPoBox()) - .withValue(StructuredPostal.NEIGHBORHOOD, address.getExtendedAddress()) - .withValue(StructuredPostal.CITY, address.getLocality()) - .withValue(StructuredPostal.REGION, address.getRegion()) - .withValue(StructuredPostal.POSTCODE, address.getPostalCode()) - .withValue(StructuredPostal.COUNTRY, address.getCountry()); - if (typeLabel != null) - builder.withValue(StructuredPostal.LABEL, typeLabel); - return builder; - } - - protected Builder buildGroupMembership(Builder builder, String group) { - return builder - .withValue(Data.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE) - .withValue(GroupMembership.GROUP_SOURCE_ID, group); - } - - protected Builder buildURL(Builder builder, String url) { - return builder - .withValue(Data.MIMETYPE, Website.CONTENT_ITEM_TYPE) - .withValue(Website.URL, url); - } - - protected Builder buildEvent(Builder builder, DateOrTimeProperty date, int type) { - SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd", Locale.US); - if (date.getDate() == null) { - Log.i(TAG, "Ignoring contact event without date"); - return null; - } - return builder - .withValue(Data.MIMETYPE, CommonDataKinds.Event.CONTENT_ITEM_TYPE) - .withValue(CommonDataKinds.Event.TYPE, type) - .withValue(CommonDataKinds.Event.START_DATE, formatter.format(date.getDate())); - } - - protected Builder buildRelated(Builder builder, RelatedType type, String name) { - int typeCode; - String typeLabel = null; - if (type == RelatedType.CHILD) - typeCode = Relation.TYPE_CHILD; - else if (type == RelatedType.CO_RESIDENT) - typeCode = Relation.TYPE_DOMESTIC_PARTNER; - else if (type == RelatedType.FRIEND) - typeCode = Relation.TYPE_FRIEND; - else if (type == RelatedType.PARENT) - typeCode = Relation.TYPE_PARENT; - else if (type == RelatedType.SPOUSE) - typeCode = Relation.TYPE_SPOUSE; - else if (type == RelatedType.KIN) - typeCode = Relation.TYPE_RELATIVE; - else if (type == RelatedType.SWEETHEART) - typeCode = Relation.TYPE_PARTNER; - else { - typeCode = Relation.TYPE_CUSTOM; - typeLabel = type.getValue(); - } - - return builder - .withValue(Data.MIMETYPE, Relation.CONTENT_ITEM_TYPE) - .withValue(Relation.TYPE, typeCode) - .withValue(Relation.NAME, name) - .withValue(Relation.LABEL, typeLabel); - } - - - /* helper methods */ - - protected Uri dataURI() { - return syncAdapterURI(Data.CONTENT_URI); - } - - protected static String labelToXName(String label) { - return "X-" + label.replaceAll(" ","_").replaceAll("[^\\p{L}\\p{Nd}\\-_]", "").toUpperCase(Locale.US); - } - - private Builder newDataInsertBuilder(long raw_contact_id, Integer backrefIdx) { - return newDataInsertBuilder(dataURI(), Data.RAW_CONTACT_ID, raw_contact_id, backrefIdx); - } - - protected static String xNameToLabel(String xname) { - // "X-MY_PROPERTY" - // 1. ensure lower case -> "x-my_property" - // 2. remove x- from beginning -> "my_property" - // 3. replace "_" by " " -> "my property" - // 4. capitalize -> "My Property" - String lowerCase = StringUtils.lowerCase(xname, Locale.US), - withoutPrefix = StringUtils.removeStart(lowerCase, "x-"), - withSpaces = StringUtils.replace(withoutPrefix, "_", " "); - return StringUtils.capitalize(withSpaces); - } + /*LocalContact[] queryAll() throws ContactsStorageException { + LocalContact contacts[] = (LocalContact[])queryContacts(ContactsContract.RawContacts.DELETED + "=0", null); + return contacts; + }*/ } diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalContact.java b/app/src/main/java/at/bitfire/davdroid/resource/LocalContact.java new file mode 100644 index 00000000..1616557c --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalContact.java @@ -0,0 +1,46 @@ +/* + * Copyright © 2013 – 2015 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.resource; + +import at.bitfire.vcard4android.AndroidAddressBook; +import at.bitfire.vcard4android.AndroidContact; +import at.bitfire.vcard4android.AndroidContactFactory; +import at.bitfire.vcard4android.Contact; + +public class LocalContact extends AndroidContact { + + protected LocalContact(AndroidAddressBook addressBook, long id) { + super(addressBook, id); + } + + public LocalContact(AndroidAddressBook addressBook, Contact contact) { + super(addressBook, contact); + } + + + static class Factory extends AndroidContactFactory { + static final Factory INSTANCE = new Factory(); + + @Override + public LocalContact newInstance(AndroidAddressBook addressBook, long id) { + return new LocalContact(addressBook, id); + } + + @Override + public LocalContact newInstance(AndroidAddressBook addressBook, Contact contact) { + return new LocalContact(addressBook, contact); + } + + public LocalContact[] newArray(int size) { + return new LocalContact[size]; + } + + } + +} diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncAdapterService.java b/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncAdapterService.java index 669a514f..602a279e 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncAdapterService.java +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncAdapterService.java @@ -9,20 +9,29 @@ package at.bitfire.davdroid.syncadapter; import android.accounts.Account; import android.app.Service; +import android.content.AbstractThreadedSyncAdapter; import android.content.ContentProviderClient; import android.content.Context; import android.content.Intent; +import android.content.SyncResult; +import android.os.Bundle; import android.os.IBinder; import android.util.Log; -import java.net.URISyntaxException; -import java.util.HashMap; -import java.util.Map; +import com.squareup.okhttp.HttpUrl; +import com.squareup.okhttp.MediaType; +import com.squareup.okhttp.ResponseBody; -import at.bitfire.davdroid.resource.CardDavAddressBook; +import org.apache.commons.io.Charsets; + +import at.bitfire.dav4android.DavAddressBook; +import at.bitfire.dav4android.DavResource; +import at.bitfire.dav4android.property.SupportedAddressData; +import at.bitfire.davdroid.Constants; +import at.bitfire.davdroid.HttpClient; import at.bitfire.davdroid.resource.LocalAddressBook; -import at.bitfire.davdroid.resource.LocalCollection; -import at.bitfire.davdroid.resource.WebDavCollection; +import at.bitfire.davdroid.resource.LocalContact; +import at.bitfire.vcard4android.Contact; public class ContactsSyncAdapterService extends Service { private static ContactsSyncAdapter syncAdapter; @@ -35,7 +44,6 @@ public class ContactsSyncAdapterService extends Service { @Override public void onDestroy() { - syncAdapter.close(); syncAdapter = null; } @@ -45,37 +53,53 @@ public class ContactsSyncAdapterService extends Service { } - private static class ContactsSyncAdapter extends DavSyncAdapter { - private final static String TAG = "davdroid.ContactsSync"; + private static class ContactsSyncAdapter extends AbstractThreadedSyncAdapter { - private ContactsSyncAdapter(Context context) { - super(context); - } + public ContactsSyncAdapter(Context context) { + super(context, false); + } - @Override - protected Map, WebDavCollection> getSyncPairs(Account account, ContentProviderClient provider) { - AccountSettings settings = new AccountSettings(getContext(), account); - String userName = settings.getUserName(), - password = settings.getPassword(); - boolean preemptive = settings.getPreemptiveAuth(); + @Override + public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) { + Constants.log.info("Starting sync for authority " + authority); - String addressBookURL = settings.getAddressBookURL(); - if (addressBookURL == null) - return null; - - try { - LocalCollection database = new LocalAddressBook(account, provider, settings); - WebDavCollection dav = new CardDavAddressBook(settings, httpClient, addressBookURL, userName, password, preemptive); - - Map, WebDavCollection> map = new HashMap<>(); - map.put(database, dav); - - return map; - } catch (URISyntaxException ex) { - Log.e(TAG, "Couldn't build address book URI", ex); - } - - return null; - } - } + AccountSettings settings = new AccountSettings(getContext(), account); + HttpClient httpClient = new HttpClient(settings.getUserName(), settings.getPassword(), settings.getPreemptiveAuth()); + + DavAddressBook dav = new DavAddressBook(httpClient, HttpUrl.parse(settings.getAddressBookURL())); + try { + boolean hasVCard4 = false; + dav.propfind(0, SupportedAddressData.NAME); + SupportedAddressData supportedAddressData = (SupportedAddressData)dav.properties.get(SupportedAddressData.NAME); + if (supportedAddressData != null) + for (MediaType type : supportedAddressData.types) + if ("text/vcard; version=4.0".equalsIgnoreCase(type.toString())) + hasVCard4 = true; + Constants.log.info("Server advertises VCard/4 support: " + hasVCard4); + + LocalAddressBook addressBook = new LocalAddressBook(account, provider); + + dav.queryMemberETags(); + for (DavResource vCard : dav.members) { + Constants.log.info("Found remote VCard: " + vCard.location); + ResponseBody body = vCard.get("text/vcard;q=0.8, text/vcard;version=4.0"); + + Contact contacts[] = Contact.fromStream(body.byteStream(), body.contentType().charset(Charsets.UTF_8)); + if (contacts.length == 1) { + Contact contact = contacts[0]; + Constants.log.info(contact.toString()); + + LocalContact localContact = new LocalContact(addressBook, contact); + localContact.add(); + } else + Constants.log.error("Received VCard with not exactly one VCARD"); + } + + } catch (Exception e) { + Log.e("davdroid", "querying member etags", e); + } + + Constants.log.info("Sync complete for authority " + authority); + } + } } diff --git a/settings.gradle b/settings.gradle index 5c630202..391e316e 100644 --- a/settings.gradle +++ b/settings.gradle @@ -8,3 +8,4 @@ include ':app' include ':dav4android' +include ':vcard4android' diff --git a/vcard4android b/vcard4android new file mode 160000 index 00000000..644ee03c --- /dev/null +++ b/vcard4android @@ -0,0 +1 @@ +Subproject commit 644ee03c74d35837974db771a7093f6f28623fbc