diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/contacts/AndroidContactTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/contacts/AndroidContactTest.kt index b6f61926..7838b220 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/contacts/AndroidContactTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/contacts/AndroidContactTest.kt @@ -9,6 +9,7 @@ package at.bitfire.synctools.storage.contacts import android.Manifest import android.accounts.Account import android.content.ContentProviderClient +import android.content.ContentValues import android.provider.ContactsContract import android.util.Base64 import androidx.test.filters.MediumTest @@ -16,13 +17,10 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import at.bitfire.synctools.mapping.contacts.Contact import at.bitfire.synctools.mapping.contacts.ContactReader -import at.bitfire.synctools.mapping.contacts.ContactWriter import at.bitfire.synctools.mapping.contacts.LabeledProperty import at.bitfire.synctools.storage.LocalStorageException import at.bitfire.synctools.vcard.VCardParser import at.bitfire.synctools.vcard.property.XAbDate -import ezvcard.VCardVersion -import ezvcard.property.Address import ezvcard.property.Birthday import ezvcard.property.Email import ezvcard.util.PartialDate @@ -31,15 +29,12 @@ import kotlinx.coroutines.test.runTest import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue import org.junit.BeforeClass import org.junit.ClassRule import org.junit.Test +import java.io.FileNotFoundException import java.io.StringReader -import java.io.StringWriter import java.time.LocalDate -import java.time.OffsetDateTime -import java.time.ZoneOffset class AndroidContactTest { @@ -135,29 +130,6 @@ class AndroidContactTest { } } - @Test - fun testBirthdayWithOffset() = runTest { - val vCard = "BEGIN:VCARD\r\n" + - "VERSION:3.0\n\n" + - "N:Doe;John;;;\n\n" + - "FN:John Doe\n\n" + - "BDAY:20010415T000000+0200\n\n" + - "END:VCARD\n\n" - val contacts = parseVCards(vCard) - - assertEquals(1, contacts.size) - contacts.first().birthDay.let { birthday -> - assertNotNull(birthday) - - val date = birthday?.date - assertNotNull(date) - - assertEquals( - OffsetDateTime.of(2001, 4, 15, 0, 0, 0, 0, ZoneOffset.ofHours(2)), date - ) - } - } - @Test @MediumTest fun testLargeTransactionManyRows() { @@ -191,31 +163,13 @@ class AndroidContactTest { contact.add() } - @Test - fun testAddressCaretEncoding() { - val address = Address() - address.label = "My \"Label\"\nLine 2" - address.streetAddress = "Street \"Address\"" - val contact = Contact() - contact.addresses += LabeledProperty(address) - - /* label-param = "LABEL=" param-value - * param-values must not contain DQUOTE and should be encoded as defined in RFC 6868 - * - * ADR-value = ADR-component-pobox ";" ADR-component-ext ";" - * ADR-component-street ";" ADR-component-locality ";" - * ADR-component-region ";" ADR-component-code ";" - * ADR-component-country - * ADR-component-pobox = list-component - * - * list-component = component *("," component) - * component = "\\" / "\," / "\;" / "\n" / WSP / NON-ASCII / %x21-2B / %x2D-3A / %x3C-5B / %x5D-7E - * - * So, ADR value components may contain DQUOTE (0x22) and don't have to be encoded as defined in RFC 6868 */ - - val writer = StringWriter() - ContactWriter(contact, VCardVersion.V4_0, testProductId).writeVCard(writer) - assertTrue(writer.toString().contains("ADR;LABEL=My ^'Label^'\\nLine 2:;;Street \"Address\";;;;")) + @Test(expected = FileNotFoundException::class) + fun testGetContactNotFound() { + val values = ContentValues() + values.put(ContactsContract.RawContacts._ID, Long.MAX_VALUE) + values.put(AndroidContact.COLUMN_FILENAME, "nonexistent.vcf") + values.put(AndroidContact.COLUMN_ETAG, "etag") + AndroidContact(addressBook, values).getContact() } diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactBuilder.kt new file mode 100644 index 00000000..e14047ec --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactBuilder.kt @@ -0,0 +1,62 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.mapping.contacts + +import android.net.Uri +import at.bitfire.synctools.mapping.contacts.builder.DataRowBuilder +import at.bitfire.synctools.mapping.contacts.builder.EmailBuilder +import at.bitfire.synctools.mapping.contacts.builder.EventBuilder +import at.bitfire.synctools.mapping.contacts.builder.ImBuilder +import at.bitfire.synctools.mapping.contacts.builder.NicknameBuilder +import at.bitfire.synctools.mapping.contacts.builder.NoteBuilder +import at.bitfire.synctools.mapping.contacts.builder.OrganizationBuilder +import at.bitfire.synctools.mapping.contacts.builder.PhoneBuilder +import at.bitfire.synctools.mapping.contacts.builder.PhotoBuilder +import at.bitfire.synctools.mapping.contacts.builder.RelationBuilder +import at.bitfire.synctools.mapping.contacts.builder.SipAddressBuilder +import at.bitfire.synctools.mapping.contacts.builder.StructuredNameBuilder +import at.bitfire.synctools.mapping.contacts.builder.StructuredPostalBuilder +import at.bitfire.synctools.mapping.contacts.builder.WebsiteBuilder +import at.bitfire.synctools.storage.contacts.ContactsBatchOperation + +class RawContactBuilder { + + private val dataRowBuilderFactories = mutableListOf>( + EmailBuilder.Factory, + EventBuilder.Factory, + ImBuilder.Factory, + NicknameBuilder.Factory, + NoteBuilder.Factory, + OrganizationBuilder.Factory, + PhoneBuilder.Factory, + PhotoBuilder.Factory, + RelationBuilder.Factory, + SipAddressBuilder.Factory, + StructuredNameBuilder.Factory, + StructuredPostalBuilder.Factory, + WebsiteBuilder.Factory + ) + + fun registerBuilderFactory(factory: DataRowBuilder.Factory<*>) { + dataRowBuilderFactories += factory + } + + fun insertDataRows(dataRowUri: Uri, rawContactId: Long?, contact: Contact, batch: ContactsBatchOperation, readOnly: Boolean) { + for (factory in dataRowBuilderFactories) { + val builder = factory.newInstance(dataRowUri, rawContactId, contact, readOnly) + batch += builder.build() + } + } + + fun builderMimeTypes(): Set { + val mimeTypes = mutableSetOf() + for (factory in dataRowBuilderFactories) + mimeTypes += factory.mimeType() + return mimeTypes + } + +} diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/ContactProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandler.kt similarity index 54% rename from lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/ContactProcessor.kt rename to lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandler.kt index c890d21d..6dfe6df8 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/ContactProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandler.kt @@ -8,22 +8,7 @@ package at.bitfire.synctools.mapping.contacts import android.content.ContentProviderClient import android.content.ContentValues -import android.net.Uri import android.provider.ContactsContract.RawContacts -import at.bitfire.synctools.mapping.contacts.builder.DataRowBuilder -import at.bitfire.synctools.mapping.contacts.builder.EmailBuilder -import at.bitfire.synctools.mapping.contacts.builder.EventBuilder -import at.bitfire.synctools.mapping.contacts.builder.ImBuilder -import at.bitfire.synctools.mapping.contacts.builder.NicknameBuilder -import at.bitfire.synctools.mapping.contacts.builder.NoteBuilder -import at.bitfire.synctools.mapping.contacts.builder.OrganizationBuilder -import at.bitfire.synctools.mapping.contacts.builder.PhoneBuilder -import at.bitfire.synctools.mapping.contacts.builder.PhotoBuilder -import at.bitfire.synctools.mapping.contacts.builder.RelationBuilder -import at.bitfire.synctools.mapping.contacts.builder.SipAddressBuilder -import at.bitfire.synctools.mapping.contacts.builder.StructuredNameBuilder -import at.bitfire.synctools.mapping.contacts.builder.StructuredPostalBuilder -import at.bitfire.synctools.mapping.contacts.builder.WebsiteBuilder import at.bitfire.synctools.mapping.contacts.handler.DataRowHandler import at.bitfire.synctools.mapping.contacts.handler.EmailHandler import at.bitfire.synctools.mapping.contacts.handler.EventHandler @@ -38,12 +23,12 @@ import at.bitfire.synctools.mapping.contacts.handler.SipAddressHandler import at.bitfire.synctools.mapping.contacts.handler.StructuredNameHandler import at.bitfire.synctools.mapping.contacts.handler.StructuredPostalHandler import at.bitfire.synctools.mapping.contacts.handler.WebsiteHandler -import at.bitfire.synctools.storage.contacts.ContactsBatchOperation +import at.bitfire.synctools.storage.contacts.AndroidContact import java.util.logging.Level import java.util.logging.Logger -class ContactProcessor( - val provider: ContentProviderClient? +class RawContactHandler( + provider: ContentProviderClient ) { private val dataRowHandlers = mutableMapOf>() @@ -63,29 +48,11 @@ class ContactProcessor( WebsiteHandler ) - private val dataRowBuilderFactories = mutableListOf>( - EmailBuilder.Factory, - EventBuilder.Factory, - ImBuilder.Factory, - NicknameBuilder.Factory, - NoteBuilder.Factory, - OrganizationBuilder.Factory, - PhoneBuilder.Factory, - PhotoBuilder.Factory, - RelationBuilder.Factory, - SipAddressBuilder.Factory, - StructuredNameBuilder.Factory, - StructuredPostalBuilder.Factory, - WebsiteBuilder.Factory - ) - - init { for (handler in defaultDataRowHandlers) registerHandler(handler) } - fun registerHandler(handler: DataRowHandler) { val mimeType = handler.forMimeType() val handlers = dataRowHandlers[mimeType] ?: run { @@ -97,13 +64,8 @@ class ContactProcessor( handlers += handler } - fun registerBuilderFactory(factory: DataRowBuilder.Factory<*>) { - dataRowBuilderFactories += factory - } - - fun handleRawContact(values: ContentValues, contact: Contact) { - contact.uid = values.getAsString(at.bitfire.synctools.storage.contacts.AndroidContact.COLUMN_UID) + contact.uid = values.getAsString(AndroidContact.COLUMN_UID) } fun handleDataRow(values: ContentValues, contact: Contact) { @@ -119,20 +81,4 @@ class ContactProcessor( } } - - fun insertDataRows(dataRowUri: Uri, rawContactId: Long?, contact: Contact, batch: ContactsBatchOperation, readOnly: Boolean) { - for (factory in dataRowBuilderFactories) { - val builder = factory.newInstance(dataRowUri, rawContactId, contact, readOnly) - batch += builder.build() - } - } - - - fun builderMimeTypes(): Set { - val mimeTypes = mutableSetOf() - for (factory in dataRowBuilderFactories) - mimeTypes += factory.mimeType() - return mimeTypes - } - } diff --git a/lib/src/main/kotlin/at/bitfire/synctools/storage/contacts/AndroidContact.kt b/lib/src/main/kotlin/at/bitfire/synctools/storage/contacts/AndroidContact.kt index c2b48572..af6e2c70 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/storage/contacts/AndroidContact.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/storage/contacts/AndroidContact.kt @@ -17,14 +17,17 @@ import android.provider.ContactsContract.RawContacts import android.provider.ContactsContract.RawContacts.Data import androidx.annotation.CallSuper import at.bitfire.synctools.mapping.contacts.Contact -import at.bitfire.synctools.mapping.contacts.ContactProcessor +import at.bitfire.synctools.mapping.contacts.RawContactBuilder +import at.bitfire.synctools.mapping.contacts.RawContactHandler import at.bitfire.synctools.mapping.contacts.builder.PhotoBuilder import at.bitfire.synctools.storage.BatchOperation import at.bitfire.synctools.storage.LocalStorageException import java.io.FileNotFoundException open class AndroidContact( - open val addressBook: AndroidAddressBook + open val addressBook: AndroidAddressBook, + protected open val rawContactBuilder: RawContactBuilder = RawContactBuilder(), + protected open val rawContactHandler: RawContactHandler = RawContactHandler(addressBook.provider!!) ) { companion object { @@ -43,8 +46,6 @@ open class AndroidContact( var eTag: String? = null - val processor = ContactProcessor(addressBook.provider) - /** * Creates a new instance, initialized with some metadata. Usually used to insert a contact to an address book. @@ -69,7 +70,7 @@ open class AndroidContact( * Cached copy of the [Contact]. If this is null, [getContact] must generate the [Contact] * from the database and then set this property. */ - protected var _contact: Contact? = null + private var cachedContact: Contact? = null /** * Fetches contact data from the contacts provider. @@ -79,7 +80,8 @@ open class AndroidContact( * @throws RemoteException on contact provider errors */ fun getContact(): Contact { - _contact?.let { return it } + // use cached version if available + cachedContact?.let { return it } val id = requireNotNull(id) var iter: EntityIterator? = null @@ -90,16 +92,16 @@ open class AndroidContact( if (iter.hasNext()) { val contact = Contact() - _contact = contact // process raw contact itself val e = iter.next() - processor.handleRawContact(e.entityValues, contact) + rawContactHandler.handleRawContact(e.entityValues, contact) // process data rows of raw contact for (subValue in e.subValues) - processor.handleDataRow(subValue.values, contact) + rawContactHandler.handleDataRow(subValue.values, contact) + cachedContact = contact return contact } else @@ -111,8 +113,8 @@ open class AndroidContact( } } - fun setContact(newContact: Contact) { - _contact = newContact + fun setContact(newContact: Contact?) { + cachedContact = newContact } @@ -152,7 +154,7 @@ open class AndroidContact( // - We don't delete group memberships because they're managed separately. // - 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. - val typesToRemove = processor.builderMimeTypes() + val typesToRemove = rawContactBuilder.builderMimeTypes() val sqlTypesToRemove = typesToRemove.joinToString(",") { mimeType -> DatabaseUtils.sqlEscapeString(mimeType) } @@ -206,7 +208,7 @@ open class AndroidContact( */ protected fun insertDataRows(batch: ContactsBatchOperation) { val contact = getContact() - processor.insertDataRows(dataSyncURI(), id, contact, batch, addressBook.readOnly) + rawContactBuilder.insertDataRows(dataSyncURI(), id, contact, batch, addressBook.readOnly) } @@ -220,6 +222,6 @@ open class AndroidContact( fun dataSyncURI() = addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI) override fun toString() = - "AndroidContact(id=$id, fileName=$fileName, eTag=$eTag, _contact=$_contact)" + "AndroidContact(id=$id, fileName=$fileName, eTag=$eTag, cachedContact=$cachedContact)" } \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/storage/contacts/AndroidGroup.kt b/lib/src/main/kotlin/at/bitfire/synctools/storage/contacts/AndroidGroup.kt index ca1617ed..05924f60 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/storage/contacts/AndroidGroup.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/storage/contacts/AndroidGroup.kt @@ -50,7 +50,7 @@ open class AndroidGroup( } constructor(addressBook: AndroidAddressBook, contact: Contact, fileName: String? = null, eTag: String? = null): this(addressBook) { - _contact = contact + cachedContact = contact this.fileName = fileName this.eTag = eTag } @@ -60,7 +60,7 @@ open class AndroidGroup( * Cached copy of the [Contact]. If this is null, [getContact] must generate the [Contact] * from the database and then set this property. */ - protected var _contact: Contact? = null + private var cachedContact: Contact? = null /** * Fetches group data from the content provider. @@ -70,7 +70,7 @@ open class AndroidGroup( * @throws RemoteException on contact provider errors */ fun getContact(): Contact { - _contact?.let { return it } + cachedContact?.let { return it } val id = requireNotNull(id) val contact = Contact() @@ -110,7 +110,7 @@ open class AndroidGroup( } } - _contact = contact + cachedContact = contact return contact } @@ -157,7 +157,7 @@ open class AndroidGroup( * @throws RemoteException on contact provider errors */ fun update(data: Contact): Uri { - _contact = data + cachedContact = data return update(contentValues()) } @@ -178,6 +178,6 @@ open class AndroidGroup( } override fun toString() = - "AndroidGroup(id=$id, fileName=$fileName, eTag=$eTag, _contact=$_contact)" + "AndroidGroup(id=$id, fileName=$fileName, eTag=$eTag, cachedContact=$cachedContact)" } diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/contacts/ContactReaderTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/contacts/ContactReaderTest.kt index 0ee44f9d..cb930aea 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/contacts/ContactReaderTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/contacts/ContactReaderTest.kt @@ -7,6 +7,7 @@ package at.bitfire.synctools.mapping.contacts import at.bitfire.synctools.mapping.contacts.Contact.Downloader +import at.bitfire.synctools.vcard.VCardParser import at.bitfire.synctools.vcard.property.CustomType import at.bitfire.synctools.vcard.property.XAbDate import at.bitfire.synctools.vcard.property.XAbLabel @@ -50,11 +51,15 @@ import kotlinx.coroutines.test.runTest import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +import java.io.StringReader import java.net.URI import java.time.LocalDate +import java.time.OffsetDateTime +import java.time.ZoneOffset class ContactReaderTest { @@ -121,6 +126,29 @@ class ContactReaderTest { assertEquals(b, c.birthDay) } + @Test + fun testBirthday_vCard3_WithOffset() = runTest { + val vCard = "BEGIN:VCARD\r\n" + + "VERSION:3.0\n\n" + + "N:Doe;John;;;\n\n" + + "FN:John Doe\n\n" + + "BDAY:20010415T000000+0200\n\n" + + "END:VCARD\n\n" + val contacts = VCardParser().parse(StringReader(vCard)).map { ContactReader.fromVCard(it) } + + assertEquals(1, contacts.size) + contacts.first().birthDay.let { birthday -> + assertNotNull(birthday) + + val date = birthday?.date + assertNotNull(date) + + assertEquals( + OffsetDateTime.of(2001, 4, 15, 0, 0, 0, 0, ZoneOffset.ofHours(2)), date + ) + } + } + @Test fun testCategories() = runTest { diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/contacts/ContactWriterTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/contacts/ContactWriterTest.kt index 083f17af..41a0798f 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/contacts/ContactWriterTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/contacts/ContactWriterTest.kt @@ -37,6 +37,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +import java.io.StringWriter import java.net.URI import java.time.LocalDate @@ -56,6 +57,33 @@ class ContactWriterTest { assertEquals(address, vCard.addresses.first()) } + @Test + fun testAddressCaretEncoding() { + val address = Address() + address.label = "My \"Label\"\nLine 2" + address.streetAddress = "Street \"Address\"" + val contact = Contact() + contact.addresses += LabeledProperty(address) + + /* label-param = "LABEL=" param-value + * param-values must not contain DQUOTE and should be encoded as defined in RFC 6868 + * + * ADR-value = ADR-component-pobox ";" ADR-component-ext ";" + * ADR-component-street ";" ADR-component-locality ";" + * ADR-component-region ";" ADR-component-code ";" + * ADR-component-country + * ADR-component-pobox = list-component + * + * list-component = component *("," component) + * component = "\\" / "\," / "\;" / "\n" / WSP / NON-ASCII / %x21-2B / %x2D-3A / %x3C-5B / %x5D-7E + * + * So, ADR value components may contain DQUOTE (0x22) and don't have to be encoded as defined in RFC 6868 */ + + val writer = StringWriter() + ContactWriter(contact, VCardVersion.V4_0, testProductId).writeVCard(writer) + assertTrue(writer.toString().contains("ADR;LABEL=My ^'Label^'\\nLine 2:;;Street \"Address\";;;;")) + } + @Test fun testAnniversary_vCard3() {