diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 56236e8f1b87d169815d348f24eade1579ff5a1b..26cab5b72114748a0b0b1dc63b9ff73ec4ecfbdc 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -15,4 +15,4 @@ cache: build: stage: build script: - - ./gradlew build \ No newline at end of file + - ./gradlew build diff --git a/README.md b/README.md index 0ce4969a215f42bb57110bc603e03cb59e251e43..06e89f7c1cfd7754b552ea93796e5e9f7268d4e4 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,15 @@ It's a framework for from/into data classes that are compatible with the Android Contacts Provider, and * accessing the Android Contacts Provider by a unified API. -It has been primarily developed for [DAVdroid](https://www.davdroid.com). +It has been primarily developed for [DAVx⁵](https://www.davx5.com). + +_This software is not affiliated to, nor has it been authorized, sponsored or otherwise approved +by Google LLC. Android is a trademark of Google LLC._ Generated KDoc: https://bitfireAT.gitlab.io/vcard4android/dokka/vcard4android/ +Discussion: https://forums.bitfire.at/category/18/libraries + ## Contact @@ -26,9 +31,6 @@ Florastraße 27 Email: [play@bitfire.at](mailto:play@bitfire.at) (do not use this) -For questions, suggestions etc. please use the DAVdroid forum: -https://www.davdroid.com/forums/ - ## License diff --git a/build.gradle b/build.gradle index 1a00df96b6047deaa60ead33d5f8a37c84d9abb7..3a91bc975d55476aac48a8492abcca446f33ca93 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,9 @@ buildscript { - ext.kotlin_version = '1.3.10' - ext.dokka_version = '0.9.17' + ext.versions = [ + kotlin: '1.3.61', + dokka: '0.10.0' + ] repositories { google() @@ -9,9 +11,9 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:3.2.1' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath "org.jetbrains.dokka:dokka-android-gradle-plugin:$dokka_version" + classpath 'com.android.tools.build:gradle:3.5.3' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}" + classpath "org.jetbrains.dokka:dokka-gradle-plugin:${versions.dokka}" } } @@ -22,16 +24,22 @@ repositories { apply plugin: 'com.android.library' apply plugin: 'kotlin-android' -apply plugin: 'org.jetbrains.dokka-android' +apply plugin: 'org.jetbrains.dokka' android { - compileSdkVersion 27 - buildToolsVersion '27.1.1' + compileSdkVersion 29 + buildToolsVersion '29.0.2' defaultConfig { - minSdkVersion 24 - targetSdkVersion 27 + minSdkVersion 24 + targetSdkVersion 29 } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + buildTypes { release { minifyEnabled false @@ -49,18 +57,26 @@ android { } defaultConfig { - testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + dokka.configuration { + sourceLink { + url = "https://gitlab.com/bitfireAT/vcard4android/tree/master/" + lineSuffix = "#L" + } + jdkVersion = 7 } } dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${versions.kotlin}" - implementation 'org.apache.commons:commons-text:1.1' + implementation 'org.apache.commons:commons-text:1.8' implementation 'commons-io:commons-io:2.6' // ez-vcard to parse/generate VCards - api('com.googlecode.ez-vcard:ez-vcard:0.10.4') { + api('com.googlecode.ez-vcard:ez-vcard:0.10.5') { // hCard functionality not needed exclude group: 'org.jsoup' exclude group: 'org.freemarker' @@ -68,8 +84,8 @@ dependencies { exclude group: 'com.fasterxml.jackson.core' } - androidTestImplementation 'com.android.support.test:runner:1.0.2' - androidTestImplementation 'com.android.support.test:rules:1.0.2' + androidTestImplementation 'androidx.test:runner:1.2.0' + androidTestImplementation 'androidx.test:rules:1.2.0' testImplementation 'junit:junit:4.12' } diff --git a/gradle.properties b/gradle.properties index 8bd86f6805108dec87d0be823bdb1384bec8aa19..d9cf55df7c1945850a53322a299a58f4b3229097 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1,2 @@ org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 5801d8e8035b2cc3012598ce30a163b41d437654..dcc77281b370672855b0e5f012b67acf3db3e2a6 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Tue Aug 23 16:42:17 CEST 2016 +#Wed Apr 17 22:31:47 CEST 2019 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.8-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-5.5.1-all.zip diff --git a/src/androidTest/java/foundation/e/vcard4android/AndroidAddressBookTest.kt b/src/androidTest/java/foundation/e/vcard4android/AndroidAddressBookTest.kt index aed4338cf57ab697bc9ee2f7f4eea92b9886e20d..060b7f7fe67792e8b4e34e0865f42747dcc48ee9 100644 --- a/src/androidTest/java/foundation/e/vcard4android/AndroidAddressBookTest.kt +++ b/src/androidTest/java/foundation/e/vcard4android/AndroidAddressBookTest.kt @@ -13,8 +13,8 @@ import android.accounts.Account import android.content.ContentProviderClient import android.content.ContentValues import android.provider.ContactsContract -import android.support.test.InstrumentationRegistry -import android.support.test.rule.GrantPermissionRule +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule import foundation.e.vcard4android.impl.TestAddressBook import org.junit.After import org.junit.Assert.* @@ -33,14 +33,15 @@ class AndroidAddressBookTest { @Before fun connect() { - val context = InstrumentationRegistry.getContext() - provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY) + val context = InstrumentationRegistry.getInstrumentation().context + provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!! assertNotNull(provider) } @After fun disconnect() { - provider.release() + @Suppress("DEPRECATION") + provider.release() } diff --git a/src/androidTest/java/foundation/e/vcard4android/AndroidContactTest.kt b/src/androidTest/java/foundation/e/vcard4android/AndroidContactTest.kt index 74edbbb494bd9324ba4d9e28f6014bbfba5000e6..026f17841ea654432f5b7d7bbe5dda39bb0e1677 100644 --- a/src/androidTest/java/foundation/e/vcard4android/AndroidContactTest.kt +++ b/src/androidTest/java/foundation/e/vcard4android/AndroidContactTest.kt @@ -12,13 +12,14 @@ import android.Manifest import android.accounts.Account import android.content.ContentProviderClient import android.provider.ContactsContract -import android.support.test.InstrumentationRegistry -import android.support.test.filters.MediumTest -import android.support.test.filters.SmallTest -import android.support.test.rule.GrantPermissionRule import android.util.Base64 +import androidx.test.filters.MediumTest +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule import foundation.e.vcard4android.impl.TestAddressBook import ezvcard.VCardVersion +import ezvcard.parameter.EmailType import ezvcard.property.Address import ezvcard.property.Birthday import ezvcard.property.Email @@ -45,8 +46,8 @@ class AndroidContactTest { @Before fun connect() { - val context = InstrumentationRegistry.getContext() - provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY) + val context = InstrumentationRegistry.getInstrumentation().context + provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!! assertNotNull(provider) addressBook = TestAddressBook(testAccount, provider) @@ -54,6 +55,7 @@ class AndroidContactTest { @After fun disconnect() { + @Suppress("DEPRECATION") provider.release() } @@ -201,6 +203,50 @@ class AndroidContactTest { } } + @Test + @SmallTest + fun testEmailTypes() { + val vCard = "BEGIN:VCARD\r\n" + + "VERSION:4.0\r\n" + + "FN:Test\r\n" + + "EMAIL;TYPE=internet;TYPE=work:work@example.com\r\n" + + "EMAIL;TYPE=home:home@example.com\r\n" + + "EMAIL;TYPE=internet,pref:other1@example.com\r\n" + + "EMAIL;TYPE=x400,other:other2@example.com\r\n" + + "EMAIL;TYPE=x-mobile:mobile@example.com\r\n" + + "END:VCARD\r\n" + val contacts = Contact.fromReader(StringReader(vCard), null) + + val dbContact = AndroidContact(addressBook, contacts.first(), null, null) + dbContact.add() + + val dbContact2 = addressBook.findContactByID(dbContact.id!!) + try { + val contact2 = dbContact2.contact!! + assertEquals("work@example.com", contact2.emails[0].property.value) + assertArrayEquals(arrayOf(EmailType.WORK), contact2.emails[0].property.types.toTypedArray()) + assertNull(contact2.emails[0].property.pref) + + assertEquals("home@example.com", contact2.emails[1].property.value) + assertArrayEquals(arrayOf(EmailType.HOME), contact2.emails[1].property.types.toTypedArray()) + assertNull(contact2.emails[1].property.pref) + + assertEquals("other1@example.com", contact2.emails[2].property.value) + assertTrue(contact2.emails[2].property.types.isEmpty()) + assertNotEquals(0, contact2.emails[2].property.pref) + + assertEquals("other2@example.com", contact2.emails[3].property.value) + assertTrue(contact2.emails[3].property.types.isEmpty()) + assertNull(contact2.emails[3].property.pref) + + assertEquals("mobile@example.com", contact2.emails[4].property.value) + assertArrayEquals(arrayOf(Contact.EMAIL_TYPE_MOBILE), contact2.emails[4].property.types.toTypedArray()) + assertNull(contact2.emails[4].property.pref) + } finally { + dbContact2.delete() + } + } + @Test fun testLabelToXName() { diff --git a/src/androidTest/java/foundation/e/vcard4android/AndroidGroupTest.kt b/src/androidTest/java/foundation/e/vcard4android/AndroidGroupTest.kt index 7919c5251cc5573c46155d319ad95a48dc71f601..d7a41e210e72ad33b3349bd0df0151c83d1cfcd2 100644 --- a/src/androidTest/java/foundation/e/vcard4android/AndroidGroupTest.kt +++ b/src/androidTest/java/foundation/e/vcard4android/AndroidGroupTest.kt @@ -12,8 +12,8 @@ import android.Manifest import android.accounts.Account import android.content.ContentProviderClient import android.provider.ContactsContract -import android.support.test.InstrumentationRegistry -import android.support.test.rule.GrantPermissionRule +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule import foundation.e.vcard4android.impl.TestAddressBook import org.junit.After import org.junit.Assert.assertEquals @@ -35,8 +35,8 @@ class AndroidGroupTest { @Before fun connect() { - val context = InstrumentationRegistry.getContext() - provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY) + val context = InstrumentationRegistry.getInstrumentation().context + provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!! assertNotNull(provider) addressBook = TestAddressBook(testAccount, provider) @@ -44,6 +44,7 @@ class AndroidGroupTest { @After fun disconnect() { + @Suppress("DEPRECATION") provider.release() } diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 03e94cf866acb9efea1d19b717845c176e796e10..24b583a0df5a07b86ea4ab51ae8538dc45efebe1 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -12,7 +12,6 @@ - + diff --git a/src/main/java/foundation/e/vcard4android/AndroidAddressBook.kt b/src/main/java/foundation/e/vcard4android/AndroidAddressBook.kt index 5a8981e25edc86140eefbcfa96e74a489b9cb374..ff4b883392e8b76e729939b6f13894b3524921cc 100644 --- a/src/main/java/foundation/e/vcard4android/AndroidAddressBook.kt +++ b/src/main/java/foundation/e/vcard4android/AndroidAddressBook.kt @@ -26,6 +26,8 @@ open class AndroidAddressBook( protected val groupFactory: AndroidGroupFactory ) { + open var readOnly: Boolean = false + var settings: ContentValues /** * Retrieves [ContactsContract.Settings] for the current address book. diff --git a/src/main/java/foundation/e/vcard4android/AndroidContact.kt b/src/main/java/foundation/e/vcard4android/AndroidContact.kt index 1b15d08eee440bc83d517d53f14d315dbb28e0c4..6ed20bdcec0e321c1945a9e56721e76f05869ff1 100644 --- a/src/main/java/foundation/e/vcard4android/AndroidContact.kt +++ b/src/main/java/foundation/e/vcard4android/AndroidContact.kt @@ -17,7 +17,6 @@ import android.graphics.BitmapFactory import android.net.Uri import android.os.RemoteException import android.provider.ContactsContract -import android.provider.ContactsContract.CommonDataKinds import android.provider.ContactsContract.CommonDataKinds.* import android.provider.ContactsContract.CommonDataKinds.Email import android.provider.ContactsContract.CommonDataKinds.Nickname @@ -26,13 +25,14 @@ import android.provider.ContactsContract.CommonDataKinds.Organization import android.provider.ContactsContract.CommonDataKinds.Photo import android.provider.ContactsContract.CommonDataKinds.StructuredName import android.provider.ContactsContract.RawContacts +import android.provider.ContactsContract.RawContacts.Data import ezvcard.parameter.* import ezvcard.property.* import ezvcard.util.PartialDate import org.apache.commons.io.IOUtils import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.builder.ToStringBuilder -import org.apache.commons.lang3.text.WordUtils +import org.apache.commons.text.WordUtils import java.io.ByteArrayOutputStream import java.io.FileNotFoundException import java.io.IOException @@ -40,6 +40,7 @@ import java.text.ParseException import java.text.SimpleDateFormat import java.util.* import java.util.logging.Level +import kotlin.math.min open class AndroidContact( val addressBook: AndroidAddressBook @@ -114,7 +115,7 @@ open class AndroidContact( try { iter = RawContacts.newEntityIterator(addressBook.provider!!.query( addressBook.syncAdapterURI(ContactsContract.RawContactsEntity.CONTENT_URI), - null, ContactsContract.RawContacts._ID + "=?", arrayOf(id.toString()), null)) + null, RawContacts._ID + "=?", arrayOf(id.toString()), null)) if (iter.hasNext()) { val e = iter.next() @@ -134,8 +135,7 @@ open class AndroidContact( it.remove() } - val mimeType = values.getAsString(ContactsContract.RawContactsEntity.MIMETYPE) - when (mimeType) { + when (val mimeType = values.getAsString(ContactsContract.RawContactsEntity.MIMETYPE)) { StructuredName.CONTENT_ITEM_TYPE -> populateStructuredName(values) Phone.CONTENT_ITEM_TYPE -> @@ -251,13 +251,13 @@ open class AndroidContact( Phone.TYPE_MMS -> number.types += Contact.PHONE_TYPE_MMS Phone.TYPE_CUSTOM -> { - row.getAsString(CommonDataKinds.Phone.LABEL)?.let { + row.getAsString(Phone.LABEL)?.let { labeledNumber.label = it number.types += TelephoneType.get(labelToXName(it)) } } } - if (row.getAsInteger(CommonDataKinds.Phone.IS_PRIMARY) != 0) + if (row.getAsInteger(Phone.IS_PRIMARY) != 0) number.pref = 1 contact!!.phoneNumbers += labeledNumber @@ -453,7 +453,7 @@ open class AndroidContact( } protected open fun populateEvent(row: ContentValues) { - val dateStr = row.getAsString(CommonDataKinds.Event.START_DATE) + val dateStr = row.getAsString(Event.START_DATE) var full: Date? = null var partial: PartialDate? = null val fullFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US) @@ -572,14 +572,31 @@ open class AndroidContact( buildContact(builder, true) batch.enqueue(BatchOperation.Operation(builder)) - // delete known data rows before adding the new ones; don't delete group memberships! + // Delete known data rows before adding the new ones. + // - We don't delete group memberships. + // - We'll only delete rows we have inserted so that unknown rows like + // vnd.android.cursor.item/important_people (= contact is in Samsung "edge panel") remain untouched. batch.enqueue(BatchOperation.Operation( ContentProviderOperation.newDelete(dataSyncURI()) - .withSelection(RawContacts.Data.RAW_CONTACT_ID + "=? AND " + RawContacts.Data.MIMETYPE + " NOT IN (?,?)", - arrayOf(id.toString(), GroupMembership.CONTENT_ITEM_TYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)) + .withSelection(Data.RAW_CONTACT_ID + "=? AND " + + Data.MIMETYPE + " IN (?,?,?,?,?,?,?,?,?,?,?,?,?)", + arrayOf(id.toString(), + StructuredName.CONTENT_ITEM_TYPE, + Phone.CONTENT_ITEM_TYPE, + Email.CONTENT_ITEM_TYPE, + Photo.CONTENT_ITEM_TYPE, + Organization.CONTENT_ITEM_TYPE, + Im.CONTENT_ITEM_TYPE, + Nickname.CONTENT_ITEM_TYPE, + Note.CONTENT_ITEM_TYPE, + StructuredPostal.CONTENT_ITEM_TYPE, + Website.CONTENT_ITEM_TYPE, + Event.CONTENT_ITEM_TYPE, + Relation.CONTENT_ITEM_TYPE, + SipAddress.CONTENT_ITEM_TYPE)) )) insertDataRows(batch) - val results = batch.commit() + batch.commit() insertPhoto(contact.photo) @@ -600,6 +617,9 @@ open class AndroidContact( .withValue(COLUMN_ETAG, eTag) .withValue(COLUMN_UID, contact!!.uid) + if (addressBook.readOnly) + builder.withValue(RawContacts.RAW_CONTACT_IS_READ_ONLY, 1) + Constants.log.log(Level.FINER, "Built RawContact data row", builder.build()) } @@ -648,7 +668,7 @@ open class AndroidContact( op = BatchOperation.Operation(builder) builder.withValue(StructuredName.RAW_CONTACT_ID, id) } - builder .withValue(RawContacts.Data.MIMETYPE, CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) + builder .withValue(Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE) .withValue(StructuredName.DISPLAY_NAME, contact.displayName) .withValue(StructuredName.PREFIX, contact.prefix) .withValue(StructuredName.GIVEN_NAME, contact.givenName) @@ -658,6 +678,10 @@ open class AndroidContact( .withValue(StructuredName.PHONETIC_GIVEN_NAME, contact.phoneticGivenName) .withValue(StructuredName.PHONETIC_MIDDLE_NAME, contact.phoneticMiddleName) .withValue(StructuredName.PHONETIC_FAMILY_NAME, contact.phoneticFamilyName) + + if (addressBook.readOnly) + builder.withValue(Data.IS_READ_ONLY, 1) + Constants.log.log(Level.FINER, "Built StructuredName data row", builder.build()) batch.enqueue(op) } @@ -674,9 +698,9 @@ open class AndroidContact( } catch(e: IllegalStateException) { Constants.log.log(Level.FINER, "Can't understand phone number PREF", e) } - var is_primary = pref != null + var isPrimary = pref != null if (types.contains(TelephoneType.PREF)) { - is_primary = true + isPrimary = true types -= TelephoneType.PREF } @@ -725,6 +749,7 @@ open class AndroidContact( types.contains(Contact.PHONE_TYPE_MMS) -> typeCode = Phone.TYPE_MMS + types.contains(Contact.PHONE_TYPE_OTHER) || types.contains(TelephoneType.VOICE) || types.contains(TelephoneType.TEXT) -> {} @@ -748,8 +773,12 @@ open class AndroidContact( .withValue(Phone.NUMBER, number.text) .withValue(Phone.TYPE, typeCode) .withValue(Phone.LABEL, typeLabel) - .withValue(Phone.IS_PRIMARY, if (is_primary) 1 else 0) - .withValue(Phone.IS_SUPER_PRIMARY, if (is_primary) 1 else 0) + .withValue(Phone.IS_PRIMARY, if (isPrimary) 1 else 0) + .withValue(Phone.IS_SUPER_PRIMARY, if (isPrimary) 1 else 0) + + if (addressBook.readOnly) + builder.withValue(Data.IS_READ_ONLY, 1) + Constants.log.log(Level.FINER, "Built Phone data row", builder.build()) batch.enqueue(op) } @@ -757,7 +786,10 @@ open class AndroidContact( protected open fun insertEmail(batch: BatchOperation, labeledEmail: LabeledProperty) { val email = labeledEmail.property + // drop TYPE=internet and TYPE=x400 because Android only knows Internet email addresses + // drop TYPE=other for compatibility, too (non-standard type which is only used by some clients and not useful as an explicit value) val types = email.types + types.removeAll(arrayOf(EmailType.INTERNET, EmailType.X400, Contact.EMAIL_TYPE_OTHER)) // preferred email address? var pref: Int? = null @@ -766,9 +798,9 @@ open class AndroidContact( } catch(e: IllegalStateException) { Constants.log.log(Level.FINER, "Can't understand email PREF", e) } - var is_primary = pref != null + var isPrimary = pref != null if (types.contains(EmailType.PREF)) { - is_primary = true + isPrimary = true types -= EmailType.PREF } @@ -784,7 +816,7 @@ open class AndroidContact( EmailType.WORK -> typeCode = Email.TYPE_WORK Contact.EMAIL_TYPE_MOBILE -> typeCode = Email.TYPE_MOBILE } - if (typeCode == 0) { + if (typeCode == 0) { // we still didn't find a known type if (email.types.isEmpty()) typeCode = Email.TYPE_OTHER else { @@ -806,8 +838,12 @@ open class AndroidContact( .withValue(Email.ADDRESS, email.value) .withValue(Email.TYPE, typeCode) .withValue(Email.LABEL, typeLabel) - .withValue(Email.IS_PRIMARY, if (is_primary) 1 else 0) - .withValue(Phone.IS_SUPER_PRIMARY, if (is_primary) 1 else 0) + .withValue(Email.IS_PRIMARY, if (isPrimary) 1 else 0) + .withValue(Phone.IS_SUPER_PRIMARY, if (isPrimary) 1 else 0) + + if (addressBook.readOnly) + builder.withValue(Data.IS_READ_ONLY, 1) + Constants.log.log(Level.FINER, "Built Email data row", builder.build()) batch.enqueue(op) } @@ -841,6 +877,10 @@ open class AndroidContact( .withValue(Organization.DEPARTMENT, department) .withValue(Organization.TITLE, contact.jobTitle) .withValue(Organization.JOB_DESCRIPTION, contact.jobDescription) + + if (addressBook.readOnly) + builder.withValue(Data.IS_READ_ONLY, 1) + Constants.log.log(Level.FINER, "Built Organization data row", builder.build()) batch.enqueue(op) } @@ -850,7 +890,6 @@ open class AndroidContact( var typeCode: Int = Im.TYPE_OTHER var typeLabel: String? = null - if (labeledImpp.label != null) { typeCode = Im.TYPE_CUSTOM typeLabel = labeledImpp.label @@ -905,6 +944,10 @@ open class AndroidContact( op = BatchOperation.Operation(builder) builder.withValue(Im.RAW_CONTACT_ID, id) } + + if (addressBook.readOnly) + builder.withValue(Data.IS_READ_ONLY, 1) + if (sipAddress) { // save as SIP address builder .withValue(SipAddress.MIMETYPE, SipAddress.CONTENT_ITEM_TYPE) @@ -958,6 +1001,10 @@ open class AndroidContact( .withValue(Nickname.NAME, nick.values.first()) .withValue(Nickname.TYPE, typeCode) .withValue(Nickname.LABEL, typeLabel) + + if (addressBook.readOnly) + builder.withValue(Data.IS_READ_ONLY, 1) + Constants.log.log(Level.FINER, "Built Nickname data row", builder.build()) batch.enqueue(op) } @@ -977,6 +1024,10 @@ open class AndroidContact( } builder .withValue(Note.MIMETYPE, Note.CONTENT_ITEM_TYPE) .withValue(Note.NOTE, contact.note) + + if (addressBook.readOnly) + builder.withValue(Data.IS_READ_ONLY, 1) + Constants.log.log(Level.FINER, "Built Note data row", builder.build()) batch.enqueue(op) } @@ -998,9 +1049,9 @@ open class AndroidContact( val lineLocality = arrayOf(address.postalCode, address.locality).filterNot { it.isNullOrEmpty() }.joinToString(" ") val lines = LinkedList() - if (!lineStreet.isEmpty()) + if (lineStreet.isNotEmpty()) lines += lineStreet - if (!lineLocality.isEmpty()) + if (lineLocality.isNotEmpty()) lines += lineLocality if (!address.region.isNullOrEmpty()) lines += address.region @@ -1010,20 +1061,21 @@ open class AndroidContact( formattedAddress = lines.joinToString("\n") } + val types = address.types var typeCode = StructuredPostal.TYPE_OTHER var typeLabel: String? = null if (labeledAddress.label != null) { typeCode = StructuredPostal.TYPE_CUSTOM typeLabel = labeledAddress.label } else { - for (type in address.types) - when (type) { - AddressType.HOME -> typeCode = StructuredPostal.TYPE_HOME - AddressType.WORK -> typeCode = StructuredPostal.TYPE_WORK + when { + types.contains(AddressType.HOME) -> typeCode = StructuredPostal.TYPE_HOME + types.contains(AddressType.WORK) -> typeCode = StructuredPostal.TYPE_WORK + types.contains(Contact.ADDRESS_TYPE_OTHER) -> {} + types.isNotEmpty() -> { + typeCode = StructuredPostal.TYPE_CUSTOM + typeLabel = xNameToLabel(address.types.first().value) } - if (typeCode == StructuredPostal.TYPE_OTHER && address.types.isNotEmpty()) { - typeCode = StructuredPostal.TYPE_CUSTOM - typeLabel = xNameToLabel(address.types.first().value) } } @@ -1046,6 +1098,10 @@ open class AndroidContact( .withValue(StructuredPostal.REGION, address.region) .withValue(StructuredPostal.POSTCODE, address.postalCode) .withValue(StructuredPostal.COUNTRY, address.country) + + if (addressBook.readOnly) + builder.withValue(Data.IS_READ_ONLY, 1) + Constants.log.log(Level.FINER, "Built StructuredPostal data row", builder.build()) batch.enqueue(op) } @@ -1087,6 +1143,10 @@ open class AndroidContact( .withValue(Website.URL, url.value) .withValue(Website.TYPE, typeCode) .withValue(Website.LABEL, typeLabel) + + if (addressBook.readOnly) + builder.withValue(Data.IS_READ_ONLY, 1) + Constants.log.log(Level.FINER, "Built Website data row", builder.build()) batch.enqueue(op) } @@ -1117,6 +1177,10 @@ open class AndroidContact( builder .withValue(Event.MIMETYPE, Event.CONTENT_ITEM_TYPE) .withValue(Event.TYPE, type) .withValue(Event.START_DATE, dateStr) + + if (addressBook.readOnly) + builder.withValue(Data.IS_READ_ONLY, 1) + batch.enqueue(op) Constants.log.log(Level.FINER, "Built Event data row", builder.build()) } @@ -1150,6 +1214,10 @@ open class AndroidContact( .withValue(Relation.NAME, related.text) .withValue(Relation.TYPE, typeCode) .withValue(Relation.LABEL, StringUtils.trimToNull(labels.joinToString(", "))) + + if (addressBook.readOnly) + builder.withValue(Data.IS_READ_ONLY, 1) + Constants.log.log(Level.FINER, "Built Relation data row", builder.build()) batch.enqueue(op) } @@ -1193,7 +1261,7 @@ open class AndroidContact( if (width > max || height > max) { val scaleWidth = max/width val scaleHeight = max/height - val scale = Math.min(scaleWidth, scaleHeight) + val scale = min(scaleWidth, scaleHeight) val newWidth = (width * scale).toInt() val newHeight = (height * scale).toInt() @@ -1217,9 +1285,13 @@ open class AndroidContact( Constants.log.fine("Inserting photo BLOB for raw contact $id") val values = ContentValues(3) - values.put(RawContacts.Data.MIMETYPE, Photo.CONTENT_ITEM_TYPE) + values.put(Data.MIMETYPE, Photo.CONTENT_ITEM_TYPE) values.put(Photo.RAW_CONTACT_ID, id) values.put(Photo.PHOTO, photo) + + if (addressBook.readOnly) + values.put(Data.IS_READ_ONLY, 1) + try { addressBook.provider!!.insert(dataSyncURI(), values) } catch(e: RemoteException) { @@ -1246,11 +1318,11 @@ open class AndroidContact( protected fun rawContactSyncURI(): Uri { val id = requireNotNull(id) - return addressBook.syncAdapterURI(ContentUris.withAppendedId(ContactsContract.RawContacts.CONTENT_URI, id)) + return addressBook.syncAdapterURI(ContentUris.withAppendedId(RawContacts.CONTENT_URI, id)) } protected fun dataSyncURI() = addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI) override fun toString() = ToStringBuilder.reflectionToString(this)!! -} +} \ No newline at end of file diff --git a/src/main/java/foundation/e/vcard4android/AndroidGroup.kt b/src/main/java/foundation/e/vcard4android/AndroidGroup.kt index 64de2bb92b3ae2ad890b5542a60e0a0e8d9d66b8..57b6f2de8f7e70134f1668c24ae335b2ce7cfa57 100644 --- a/src/main/java/foundation/e/vcard4android/AndroidGroup.kt +++ b/src/main/java/foundation/e/vcard4android/AndroidGroup.kt @@ -113,6 +113,7 @@ open class AndroidGroup( * Creates a group with data taken from the constructor. * @return number of affected rows * @throws RemoteException on contact provider errors + * @throws ContactsStorageException when the group can't be added */ fun add(): Uri { val values = contentValues() @@ -121,6 +122,7 @@ open class AndroidGroup( values.put(Groups.SHOULD_SYNC, 1) // read-only: values.put(Groups.GROUP_VISIBLE, 1); val uri = addressBook.provider!!.insert(addressBook.syncAdapterURI(Groups.CONTENT_URI), values) + ?: throw ContactsStorageException("Empty result from content provider when adding group") id = ContentUris.parseId(uri) return uri } @@ -150,7 +152,7 @@ open class AndroidGroup( private fun groupSyncURI(): Uri { val id = requireNotNull(id) - return addressBook.syncAdapterURI(ContentUris.withAppendedId(ContactsContract.Groups.CONTENT_URI, id)) + return addressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, id)) } override fun toString() = ToStringBuilder.reflectionToString(this)!! diff --git a/src/main/java/foundation/e/vcard4android/Contact.kt b/src/main/java/foundation/e/vcard4android/Contact.kt index 5147dbed6ee436619a1091e23237654f7f221db5..59052fce32a9ae5f6fbe34474574ed280a03ecd8 100644 --- a/src/main/java/foundation/e/vcard4android/Contact.kt +++ b/src/main/java/foundation/e/vcard4android/Contact.kt @@ -11,6 +11,7 @@ package foundation.e.vcard4android import ezvcard.Ezvcard import ezvcard.VCard import ezvcard.VCardVersion +import ezvcard.parameter.AddressType import ezvcard.parameter.EmailType import ezvcard.parameter.ImageType import ezvcard.parameter.TelephoneType @@ -90,8 +91,16 @@ class Contact { val PHONE_TYPE_RADIO = TelephoneType.get("x-radio")!! val PHONE_TYPE_ASSISTANT = TelephoneType.get("X-assistant")!! val PHONE_TYPE_MMS = TelephoneType.get("x-mms")!! + /** Sometimes used to denote an "other" phone numbers. Only for compatibility – don't use it yourself! */ + val PHONE_TYPE_OTHER = TelephoneType.get("other")!! + /** Custom email type to denote "mobile" email addresses. */ val EMAIL_TYPE_MOBILE = EmailType.get("x-mobile")!! + /** Sometimes used to denote an "other" email address. Only for compatibility – don't use it yourself! */ + val EMAIL_TYPE_OTHER = EmailType.get("other")!! + + /** Sometimes used to denote an "other" postal address. Only for compatibility – don't use it yourself! */ + val ADDRESS_TYPE_OTHER = AddressType.get("other")!! const val NICKNAME_TYPE_MAIDEN_NAME = "x-maiden-name" const val NICKNAME_TYPE_SHORT_NAME = "x-short-name" @@ -108,12 +117,13 @@ class Contact { /** - * Parses an InputStream that contains a VCard. + * Parses an InputStream that contains a vCard. * - * @param reader reader for the input stream containing the VCard (pay attention to the charset) + * @param reader reader for the input stream containing the vCard (pay attention to the charset) * @param downloader will be used to download external resources like contact photos (may be null) * @return list of filled Event data objects (may have size 0) – doesn't return null * @throws IOException on I/O errors when reading the stream + * @throws ezvcard.io.CannotParseException when the vCard can't be parsed */ fun fromReader(reader: Reader, downloader: Downloader?): List { val vcards = Ezvcard.parse(reader).all() @@ -222,7 +232,7 @@ class Contact { extToRemove.distinct().forEach { vCard.removeExtendedProperty(it.propertyName) } if (c.uid == null) { - Constants.log.warning("Received VCard without UID, generating new one") + Constants.log.warning("Received vCard without UID, generating new one") c.uid = UUID.randomUUID().toString() } @@ -296,7 +306,7 @@ class Contact { try { unknownProperties?.let { vCard = Ezvcard.parse(unknownProperties).first() } } catch (e: Exception) { - Constants.log.fine("Couldn't parse original VCard with retained properties") + Constants.log.fine("Couldn't parse original vCard with retained properties") } // UID @@ -309,7 +319,7 @@ class Contact { if (vCardVersion == VCardVersion.V4_0) { vCard.kind = Kind.group() members.forEach { vCard.members += Member("urn:uuid:$it") } - } else { // "VCard4 as VCard3" (Apple-style) + } else { // "vCard4 as vCard3" (Apple-style) vCard.setExtendedProperty(PROPERTY_ADDRESSBOOKSERVER_KIND, Kind.GROUP) members.forEach { vCard.addExtendedProperty(PROPERTY_ADDRESSBOOKSERVER_MEMBER, "urn:uuid:$it") } } @@ -346,7 +356,7 @@ class Contact { vCard.structuredName = n } else if (vCardVersion == VCardVersion.V3_0) { - // (only) VCard 3 requires N [RFC 2426 3.1.2] + // (only) vCard 3 requires N [RFC 2426 3.1.2] if (group && groupMethod == GroupMethod.GROUP_VCARDS) { val n = StructuredName() n.family = fn @@ -373,7 +383,7 @@ class Contact { fun addLabel(labeledProperty: LabeledProperty) { labeledProperty.label?.let { - val group = "davdroid${labelIterator.incrementAndGet()}" + val group = "group${labelIterator.incrementAndGet()}" labeledProperty.property.group = group val label = vCard.addExtendedProperty(LabeledProperty.PROPERTY_AB_LABEL, it) @@ -431,17 +441,17 @@ class Contact { if (vCardVersion == VCardVersion.V4_0 || prop.date != null) return prop else prop.partialDate?.let { partial -> - // VCard 3: partial date with month and day, but without year + // vCard 3: partial date with month and day, but without year if (partial.date != null && partial.month != null) { return if (partial.year != null) // partial date is a complete date prop else { - // VCard 3: partial date with month and day, but without year + // vCard 3: partial date with month and day, but without year val fakeCal = GregorianCalendar.getInstance() fakeCal.set(DATE_PARAMETER_OMIT_YEAR_DEFAULT, partial.month - 1, partial.date) val fakeProp = generator(fakeCal.time) - fakeProp.addParameter(DATE_PARAMETER_OMIT_YEAR, Integer.toString(DATE_PARAMETER_OMIT_YEAR_DEFAULT)) + fakeProp.addParameter(DATE_PARAMETER_OMIT_YEAR, DATE_PARAMETER_OMIT_YEAR_DEFAULT.toString()) fakeProp } } @@ -460,20 +470,20 @@ class Contact { // REV vCard.revision = Revision.now() - // validate VCard and log results + // validate vCard and log results val validation = vCard.validate(vCardVersion) if (!validation.isEmpty) { val msgs = LinkedList() for ((key, warnings) in validation) msgs += " * " + key.javaClass.simpleName + " - " + warnings.joinToString(" | ") - Constants.log.log(Level.INFO, "Generating possibly invalid VCard", msgs.joinToString(",")) + Constants.log.log(Level.WARNING, "vCard validation warnings", msgs.joinToString(",")) } // generate VCARD Ezvcard .write(vCard) .version(vCardVersion) - .versionStrict(false) // allow VCard4 properties in VCard3s - .caretEncoding(true) // enable RFC 6868 support + .versionStrict(false) // allow vCard4 properties in vCard3s + .caretEncoding(true) // enable RFC 6868 support .prodId(productID == null) .go(os) } diff --git a/src/main/res/values/about_strings.xml b/src/main/res/values/about_strings.xml new file mode 100644 index 0000000000000000000000000000000000000000..44462aded4ff628c8e66ab91db19ae87b87ab08f --- /dev/null +++ b/src/main/res/values/about_strings.xml @@ -0,0 +1,13 @@ + + + + + Ricki Hirner + https://www.bitfire.at + vcard4android + Allows usage of vCard resources with the Android contacts provider + https://gitlab.com/bitfireAT/vcard4android + gpl_3_0 + true + + diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml deleted file mode 100644 index 1072878777a4a2b89fb5d44010f5711f2abc250e..0000000000000000000000000000000000000000 --- a/src/main/res/values/strings.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - VCard4Android - diff --git a/src/test/java/foundation/e/vcard4android/ContactTest.kt b/src/test/java/foundation/e/vcard4android/ContactTest.kt index 9fd81f075936d5d52753d9c01e78ba6eea2e27e2..3caef9cb50a8f635f0d71aa8c3113436867db704 100644 --- a/src/test/java/foundation/e/vcard4android/ContactTest.kt +++ b/src/test/java/foundation/e/vcard4android/ContactTest.kt @@ -26,7 +26,7 @@ import java.util.* class ContactTest { private fun parseContact(fname: String, charset: Charset = Charsets.UTF_8) = - javaClass.classLoader.getResourceAsStream(fname).use { stream -> + javaClass.classLoader!!.getResourceAsStream(fname).use { stream -> Contact.fromReader(InputStreamReader(stream, charset), null).first() } @@ -175,8 +175,8 @@ class ContactTest { c.uid = UUID.randomUUID().toString() c.phoneNumbers += LabeledProperty(Telephone("12345"), "My Phone") val vCard = toString(c, GroupMethod.GROUP_VCARDS, VCardVersion.V3_0) - assertTrue(vCard.contains("\ndavdroid1.TEL:12345\r\n")) - assertTrue(vCard.contains("\ndavdroid1.X-ABLabel:My Phone\r\n")) + assertTrue(vCard.contains("\ngroup1.TEL:12345\r\n")) + assertTrue(vCard.contains("\ngroup1.X-ABLabel:My Phone\r\n")) c = regenerate(c, VCardVersion.V4_0) assertEquals("12345", c.phoneNumbers.first.property.text) @@ -336,7 +336,7 @@ class ContactTest { assertEquals("muuuum", rel.text) // PHOTO - javaClass.classLoader.getResourceAsStream("lol.jpg").use { photo -> + javaClass.classLoader!!.getResourceAsStream("lol.jpg").use { photo -> assertArrayEquals(IOUtils.toByteArray(photo), c.photo) } }