From d3594b06d20e1618edf07c5558789439927bf1ab Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Fri, 22 May 2026 11:41:04 +0200 Subject: [PATCH 01/14] Add `RawContactHandler` for centralized raw contact fetching --- .../mapping/contacts/RawContactHandlerTest.kt | 89 +++++++++++++++++++ .../mapping/contacts/RawContactHandler.kt | 55 ++++++++++++ .../storage/contacts/AndroidContact.kt | 36 ++------ 3 files changed, 152 insertions(+), 28 deletions(-) create mode 100644 lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandlerTest.kt create mode 100644 lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandler.kt diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandlerTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandlerTest.kt new file mode 100644 index 00000000..ac3c2f6e --- /dev/null +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandlerTest.kt @@ -0,0 +1,89 @@ +/* + * 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.Manifest +import android.accounts.Account +import android.content.ContentProviderClient +import android.provider.ContactsContract +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import at.bitfire.synctools.storage.contacts.AndroidContact +import at.bitfire.synctools.storage.contacts.TestAddressBook +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.BeforeClass +import org.junit.ClassRule +import org.junit.Test +import java.io.FileNotFoundException + +class RawContactHandlerTest { + + companion object { + @JvmField + @ClassRule + val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!! + + private val testAddressBookAccount = Account("RawContactHandlerTest", "at.bitfire.vcard4android") + + private lateinit var provider: ContentProviderClient + private lateinit var addressBook: TestAddressBook + + @BeforeClass + @JvmStatic + fun connect() { + val context = InstrumentationRegistry.getInstrumentation().context + provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!! + assertNotNull(provider) + addressBook = TestAddressBook(testAddressBookAccount, provider) + } + + @BeforeClass + @JvmStatic + fun disconnect() { + @Suppress("DEPRECATION") + provider.release() + } + } + + + @Test + fun testFetchContact() { + val vcard = Contact() + vcard.displayName = "RawContactHandler Test" + vcard.givenName = "Test" + vcard.familyName = "Contact" + + val contact = AndroidContact(addressBook, vcard, "test.vcf", "etag1") + contact.add() + + try { + val handler = RawContactHandler( + addressBook.provider!!, + addressBook.syncAdapterURI(ContactsContract.RawContactsEntity.CONTENT_URI), + contact.processor + ) + val fetched = handler.fetchContact(contact.id!!) + assertEquals(vcard.displayName, fetched.displayName) + assertEquals(vcard.givenName, fetched.givenName) + assertEquals(vcard.familyName, fetched.familyName) + } finally { + contact.delete() + } + } + + @Test(expected = FileNotFoundException::class) + fun testFetchContactNotFound() { + val handler = RawContactHandler( + addressBook.provider!!, + addressBook.syncAdapterURI(ContactsContract.RawContactsEntity.CONTENT_URI), + ContactProcessor(addressBook.provider) + ) + handler.fetchContact(Long.MAX_VALUE) + } + +} diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandler.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandler.kt new file mode 100644 index 00000000..968b7dc6 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandler.kt @@ -0,0 +1,55 @@ +/* + * 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.content.ContentProviderClient +import android.content.EntityIterator +import android.net.Uri +import android.provider.ContactsContract.RawContacts +import java.io.FileNotFoundException + +class RawContactHandler( + private val provider: ContentProviderClient, + private val rawContactsEntityUri: Uri, + private val processor: ContactProcessor +) { + + /** + * Fetches a raw contact from the contacts provider and converts it to a [Contact]. + * + * @throws FileNotFoundException when no raw contact with the given [id] exists + */ + fun fetchContact(id: Long): Contact { + var iter: EntityIterator? = null + try { + iter = RawContacts.newEntityIterator(provider.query( + rawContactsEntityUri, + null, RawContacts._ID + "=?", arrayOf(id.toString()), null)) + + if (iter.hasNext()) { + val contact = Contact() + + // process raw contact itself + val e = iter.next() + processor.handleRawContact(e.entityValues, contact) + + // process data rows of raw contact + for (subValue in e.subValues) + processor.handleDataRow(subValue.values, contact) + + return contact + + } else + // no raw contact with this ID + throw FileNotFoundException() + + } finally { + iter?.close() + } + } + +} 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..1eb940a4 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 @@ -8,7 +8,6 @@ package at.bitfire.synctools.storage.contacts import android.content.ContentUris import android.content.ContentValues -import android.content.EntityIterator import android.database.DatabaseUtils import android.net.Uri import android.os.RemoteException @@ -18,6 +17,7 @@ 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.RawContactHandler import at.bitfire.synctools.mapping.contacts.builder.PhotoBuilder import at.bitfire.synctools.storage.BatchOperation import at.bitfire.synctools.storage.LocalStorageException @@ -82,33 +82,13 @@ open class AndroidContact( _contact?.let { return it } val id = requireNotNull(id) - var iter: EntityIterator? = null - try { - iter = RawContacts.newEntityIterator(addressBook.provider!!.query( - addressBook.syncAdapterURI(ContactsContract.RawContactsEntity.CONTENT_URI), - null, RawContacts._ID + "=?", arrayOf(id.toString()), null)) - - if (iter.hasNext()) { - val contact = Contact() - _contact = contact - - // process raw contact itself - val e = iter.next() - processor.handleRawContact(e.entityValues, contact) - - // process data rows of raw contact - for (subValue in e.subValues) - processor.handleDataRow(subValue.values, contact) - - return contact - - } else - // no raw contact with this ID - throw FileNotFoundException() - - } finally { - iter?.close() - } + val contact = RawContactHandler( + addressBook.provider!!, + addressBook.syncAdapterURI(ContactsContract.RawContactsEntity.CONTENT_URI), + processor + ).fetchContact(id) + _contact = contact + return contact } fun setContact(newContact: Contact) { From c363f8a1e103baa585b1c7cfa4ac24dd96f4b52f Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Fri, 22 May 2026 11:44:58 +0200 Subject: [PATCH 02/14] Move data row handling from `ContactProcessor` to `RawContactHandler` --- .../mapping/contacts/RawContactHandlerTest.kt | 6 +- .../mapping/contacts/ContactProcessor.kt | 76 +------------------ .../mapping/contacts/RawContactHandler.kt | 76 ++++++++++++++++++- .../storage/contacts/AndroidContact.kt | 6 +- 4 files changed, 77 insertions(+), 87 deletions(-) diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandlerTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandlerTest.kt index ac3c2f6e..1e991390 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandlerTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandlerTest.kt @@ -64,8 +64,7 @@ class RawContactHandlerTest { try { val handler = RawContactHandler( addressBook.provider!!, - addressBook.syncAdapterURI(ContactsContract.RawContactsEntity.CONTENT_URI), - contact.processor + addressBook.syncAdapterURI(ContactsContract.RawContactsEntity.CONTENT_URI) ) val fetched = handler.fetchContact(contact.id!!) assertEquals(vcard.displayName, fetched.displayName) @@ -80,8 +79,7 @@ class RawContactHandlerTest { fun testFetchContactNotFound() { val handler = RawContactHandler( addressBook.provider!!, - addressBook.syncAdapterURI(ContactsContract.RawContactsEntity.CONTENT_URI), - ContactProcessor(addressBook.provider) + addressBook.syncAdapterURI(ContactsContract.RawContactsEntity.CONTENT_URI) ) handler.fetchContact(Long.MAX_VALUE) } diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/ContactProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/ContactProcessor.kt index c890d21d..a07f0e2d 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/ContactProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/ContactProcessor.kt @@ -6,10 +6,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 @@ -24,44 +21,9 @@ 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 -import at.bitfire.synctools.mapping.contacts.handler.ImHandler -import at.bitfire.synctools.mapping.contacts.handler.NicknameHandler -import at.bitfire.synctools.mapping.contacts.handler.NoteHandler -import at.bitfire.synctools.mapping.contacts.handler.OrganizationHandler -import at.bitfire.synctools.mapping.contacts.handler.PhoneHandler -import at.bitfire.synctools.mapping.contacts.handler.PhotoHandler -import at.bitfire.synctools.mapping.contacts.handler.RelationHandler -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 java.util.logging.Level -import java.util.logging.Logger -class ContactProcessor( - val provider: ContentProviderClient? -) { - - private val dataRowHandlers = mutableMapOf>() - private val defaultDataRowHandlers = arrayOf( - EmailHandler, - EventHandler, - ImHandler, - NicknameHandler, - NoteHandler, - OrganizationHandler, - PhoneHandler, - PhotoHandler(provider), - RelationHandler, - SipAddressHandler, - StructuredNameHandler, - StructuredPostalHandler, - WebsiteHandler - ) +class ContactProcessor { private val dataRowBuilderFactories = mutableListOf>( EmailBuilder.Factory, @@ -79,47 +41,11 @@ class ContactProcessor( WebsiteBuilder.Factory ) - - init { - for (handler in defaultDataRowHandlers) - registerHandler(handler) - } - - - fun registerHandler(handler: DataRowHandler) { - val mimeType = handler.forMimeType() - val handlers = dataRowHandlers[mimeType] ?: run { - val newList = mutableListOf() - dataRowHandlers[mimeType] = newList - newList - } - - 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) - } - - fun handleDataRow(values: ContentValues, contact: Contact) { - val mimeType = values.getAsString(RawContacts.Data.MIMETYPE) - - val handlers = dataRowHandlers[mimeType].orEmpty() - if (handlers.isNotEmpty()) - for (handler in handlers) - handler.handle(values, contact) - else { - val logger = Logger.getLogger(javaClass.name) - logger.log(Level.WARNING, "No registered handler for $mimeType", values) - } - } - - fun insertDataRows(dataRowUri: Uri, rawContactId: Long?, contact: Contact, batch: ContactsBatchOperation, readOnly: Boolean) { for (factory in dataRowBuilderFactories) { val builder = factory.newInstance(dataRowUri, rawContactId, contact, readOnly) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandler.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandler.kt index 968b7dc6..485b567c 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandler.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandler.kt @@ -7,17 +7,85 @@ package at.bitfire.synctools.mapping.contacts import android.content.ContentProviderClient +import android.content.ContentValues import android.content.EntityIterator import android.net.Uri import android.provider.ContactsContract.RawContacts +import at.bitfire.synctools.mapping.contacts.handler.DataRowHandler +import at.bitfire.synctools.mapping.contacts.handler.EmailHandler +import at.bitfire.synctools.mapping.contacts.handler.EventHandler +import at.bitfire.synctools.mapping.contacts.handler.ImHandler +import at.bitfire.synctools.mapping.contacts.handler.NicknameHandler +import at.bitfire.synctools.mapping.contacts.handler.NoteHandler +import at.bitfire.synctools.mapping.contacts.handler.OrganizationHandler +import at.bitfire.synctools.mapping.contacts.handler.PhoneHandler +import at.bitfire.synctools.mapping.contacts.handler.PhotoHandler +import at.bitfire.synctools.mapping.contacts.handler.RelationHandler +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.AndroidContact import java.io.FileNotFoundException +import java.util.logging.Level +import java.util.logging.Logger class RawContactHandler( private val provider: ContentProviderClient, - private val rawContactsEntityUri: Uri, - private val processor: ContactProcessor + private val rawContactsEntityUri: Uri ) { + private val dataRowHandlers = mutableMapOf>() + private val defaultDataRowHandlers = arrayOf( + EmailHandler, + EventHandler, + ImHandler, + NicknameHandler, + NoteHandler, + OrganizationHandler, + PhoneHandler, + PhotoHandler(provider), + RelationHandler, + SipAddressHandler, + StructuredNameHandler, + StructuredPostalHandler, + WebsiteHandler + ) + + init { + for (handler in defaultDataRowHandlers) + registerHandler(handler) + } + + fun registerHandler(handler: DataRowHandler) { + val mimeType = handler.forMimeType() + val handlers = dataRowHandlers[mimeType] ?: run { + val newList = mutableListOf() + dataRowHandlers[mimeType] = newList + newList + } + + handlers += handler + } + + private fun handleRawContact(values: ContentValues, contact: Contact) { + contact.uid = values.getAsString(AndroidContact.COLUMN_UID) + } + + private fun handleDataRow(values: ContentValues, contact: Contact) { + val mimeType = values.getAsString(RawContacts.Data.MIMETYPE) + + val handlers = dataRowHandlers[mimeType].orEmpty() + if (handlers.isNotEmpty()) + for (handler in handlers) + handler.handle(values, contact) + else { + val logger = Logger.getLogger(javaClass.name) + logger.log(Level.WARNING, "No registered handler for $mimeType", values) + } + } + + /** * Fetches a raw contact from the contacts provider and converts it to a [Contact]. * @@ -35,11 +103,11 @@ class RawContactHandler( // process raw contact itself val e = iter.next() - processor.handleRawContact(e.entityValues, contact) + handleRawContact(e.entityValues, contact) // process data rows of raw contact for (subValue in e.subValues) - processor.handleDataRow(subValue.values, contact) + handleDataRow(subValue.values, contact) return contact 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 1eb940a4..2e3401ab 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 @@ -21,7 +21,6 @@ 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 @@ -43,7 +42,7 @@ open class AndroidContact( var eTag: String? = null - val processor = ContactProcessor(addressBook.provider) + val processor = ContactProcessor() /** @@ -84,8 +83,7 @@ open class AndroidContact( val id = requireNotNull(id) val contact = RawContactHandler( addressBook.provider!!, - addressBook.syncAdapterURI(ContactsContract.RawContactsEntity.CONTENT_URI), - processor + addressBook.syncAdapterURI(ContactsContract.RawContactsEntity.CONTENT_URI) ).fetchContact(id) _contact = contact return contact From b1c0786cc89d52243fbc70d0867b93c1774fca90 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Fri, 22 May 2026 11:54:03 +0200 Subject: [PATCH 03/14] Move provider access back to AndroidContact --- .../mapping/contacts/RawContactHandlerTest.kt | 46 +++++++++++-------- .../storage/contacts/AndroidContactTest.kt | 11 +++++ .../mapping/contacts/RawContactHandler.kt | 45 ++---------------- .../storage/contacts/AndroidContact.kt | 36 ++++++++++++--- 4 files changed, 71 insertions(+), 67 deletions(-) diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandlerTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandlerTest.kt index 1e991390..abbdcb4b 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandlerTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandlerTest.kt @@ -9,17 +9,19 @@ package at.bitfire.synctools.mapping.contacts import android.Manifest import android.accounts.Account import android.content.ContentProviderClient +import android.content.EntityIterator import android.provider.ContactsContract +import android.provider.ContactsContract.RawContacts import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import at.bitfire.synctools.storage.contacts.AndroidContact import at.bitfire.synctools.storage.contacts.TestAddressBook 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 class RawContactHandlerTest { @@ -52,7 +54,7 @@ class RawContactHandlerTest { @Test - fun testFetchContact() { + fun testHandleRawContactAndDataRows() { val vcard = Contact() vcard.displayName = "RawContactHandler Test" vcard.givenName = "Test" @@ -62,26 +64,32 @@ class RawContactHandlerTest { contact.add() try { - val handler = RawContactHandler( - addressBook.provider!!, - addressBook.syncAdapterURI(ContactsContract.RawContactsEntity.CONTENT_URI) - ) - val fetched = handler.fetchContact(contact.id!!) - assertEquals(vcard.displayName, fetched.displayName) - assertEquals(vcard.givenName, fetched.givenName) - assertEquals(vcard.familyName, fetched.familyName) + val handler = RawContactHandler(addressBook.provider) + val entityUri = addressBook.syncAdapterURI(ContactsContract.RawContactsEntity.CONTENT_URI) + + var iter: EntityIterator? = null + try { + iter = RawContacts.newEntityIterator(addressBook.provider!!.query( + entityUri, null, RawContacts._ID + "=?", arrayOf(contact.id!!.toString()), null)) + + assertTrue(iter.hasNext()) + val e = iter.next() + + val fetched = Contact() + handler.handleRawContact(e.entityValues, fetched) + for (subValue in e.subValues) + handler.handleDataRow(subValue.values, fetched) + + assertEquals(vcard.displayName, fetched.displayName) + assertEquals(vcard.givenName, fetched.givenName) + assertEquals(vcard.familyName, fetched.familyName) + } finally { + iter?.close() + } } finally { contact.delete() } } - @Test(expected = FileNotFoundException::class) - fun testFetchContactNotFound() { - val handler = RawContactHandler( - addressBook.provider!!, - addressBook.syncAdapterURI(ContactsContract.RawContactsEntity.CONTENT_URI) - ) - handler.fetchContact(Long.MAX_VALUE) - } - } + 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..ed67b8e0 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 @@ -35,6 +36,7 @@ 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 @@ -191,6 +193,15 @@ class AndroidContactTest { contact.add() } + @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() + } + @Test fun testAddressCaretEncoding() { val address = Address() diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandler.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandler.kt index 485b567c..4d9bfc09 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandler.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandler.kt @@ -8,8 +8,6 @@ package at.bitfire.synctools.mapping.contacts import android.content.ContentProviderClient import android.content.ContentValues -import android.content.EntityIterator -import android.net.Uri import android.provider.ContactsContract.RawContacts import at.bitfire.synctools.mapping.contacts.handler.DataRowHandler import at.bitfire.synctools.mapping.contacts.handler.EmailHandler @@ -26,13 +24,11 @@ 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.AndroidContact -import java.io.FileNotFoundException import java.util.logging.Level import java.util.logging.Logger class RawContactHandler( - private val provider: ContentProviderClient, - private val rawContactsEntityUri: Uri + provider: ContentProviderClient? ) { private val dataRowHandlers = mutableMapOf>() @@ -68,11 +64,11 @@ class RawContactHandler( handlers += handler } - private fun handleRawContact(values: ContentValues, contact: Contact) { + fun handleRawContact(values: ContentValues, contact: Contact) { contact.uid = values.getAsString(AndroidContact.COLUMN_UID) } - private fun handleDataRow(values: ContentValues, contact: Contact) { + fun handleDataRow(values: ContentValues, contact: Contact) { val mimeType = values.getAsString(RawContacts.Data.MIMETYPE) val handlers = dataRowHandlers[mimeType].orEmpty() @@ -85,39 +81,4 @@ class RawContactHandler( } } - - /** - * Fetches a raw contact from the contacts provider and converts it to a [Contact]. - * - * @throws FileNotFoundException when no raw contact with the given [id] exists - */ - fun fetchContact(id: Long): Contact { - var iter: EntityIterator? = null - try { - iter = RawContacts.newEntityIterator(provider.query( - rawContactsEntityUri, - null, RawContacts._ID + "=?", arrayOf(id.toString()), null)) - - if (iter.hasNext()) { - val contact = Contact() - - // process raw contact itself - val e = iter.next() - handleRawContact(e.entityValues, contact) - - // process data rows of raw contact - for (subValue in e.subValues) - handleDataRow(subValue.values, contact) - - return contact - - } else - // no raw contact with this ID - throw FileNotFoundException() - - } finally { - iter?.close() - } - } - } 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 2e3401ab..a8b7196d 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 @@ -8,6 +8,7 @@ package at.bitfire.synctools.storage.contacts import android.content.ContentUris import android.content.ContentValues +import android.content.EntityIterator import android.database.DatabaseUtils import android.net.Uri import android.os.RemoteException @@ -21,6 +22,7 @@ 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 @@ -81,12 +83,34 @@ open class AndroidContact( _contact?.let { return it } val id = requireNotNull(id) - val contact = RawContactHandler( - addressBook.provider!!, - addressBook.syncAdapterURI(ContactsContract.RawContactsEntity.CONTENT_URI) - ).fetchContact(id) - _contact = contact - return contact + var iter: EntityIterator? = null + try { + iter = RawContacts.newEntityIterator(addressBook.provider!!.query( + addressBook.syncAdapterURI(ContactsContract.RawContactsEntity.CONTENT_URI), + null, RawContacts._ID + "=?", arrayOf(id.toString()), null)) + + val rawContactHandler = RawContactHandler(addressBook.provider) + if (iter.hasNext()) { + val contact = Contact() + + // process raw contact itself + val e = iter.next() + rawContactHandler.handleRawContact(e.entityValues, contact) + + // process data rows of raw contact + for (subValue in e.subValues) + rawContactHandler.handleDataRow(subValue.values, contact) + + _contact = contact + return contact + + } else + // no raw contact with this ID + throw FileNotFoundException() + + } finally { + iter?.close() + } } fun setContact(newContact: Contact) { From cc39736cc2b7f911222377286f4a414afbd7bae8 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Fri, 22 May 2026 11:55:46 +0200 Subject: [PATCH 04/14] Make `RawContactHandler` provider parameter non-nullable --- .../bitfire/synctools/mapping/contacts/RawContactHandlerTest.kt | 2 +- .../at/bitfire/synctools/mapping/contacts/RawContactHandler.kt | 2 +- .../at/bitfire/synctools/storage/contacts/AndroidContact.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandlerTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandlerTest.kt index abbdcb4b..83c6142a 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandlerTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandlerTest.kt @@ -64,7 +64,7 @@ class RawContactHandlerTest { contact.add() try { - val handler = RawContactHandler(addressBook.provider) + val handler = RawContactHandler(addressBook.provider!!) val entityUri = addressBook.syncAdapterURI(ContactsContract.RawContactsEntity.CONTENT_URI) var iter: EntityIterator? = null diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandler.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandler.kt index 4d9bfc09..6dfe6df8 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandler.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandler.kt @@ -28,7 +28,7 @@ import java.util.logging.Level import java.util.logging.Logger class RawContactHandler( - provider: ContentProviderClient? + provider: ContentProviderClient ) { private val dataRowHandlers = mutableMapOf>() 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 a8b7196d..9b86e3ad 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 @@ -89,7 +89,7 @@ open class AndroidContact( addressBook.syncAdapterURI(ContactsContract.RawContactsEntity.CONTENT_URI), null, RawContacts._ID + "=?", arrayOf(id.toString()), null)) - val rawContactHandler = RawContactHandler(addressBook.provider) + val rawContactHandler = RawContactHandler(addressBook.provider!!) if (iter.hasNext()) { val contact = Contact() From c7a6d7e3143fca8c7b6dd70a09ce6a7e43e84f4b Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Fri, 22 May 2026 11:59:43 +0200 Subject: [PATCH 05/14] Rename `ContactProcessor` to `RawContactBuilder` --- .../{ContactProcessor.kt => RawContactBuilder.kt} | 2 +- .../synctools/storage/contacts/AndroidContact.kt | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) rename lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/{ContactProcessor.kt => RawContactBuilder.kt} (98%) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/ContactProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactBuilder.kt similarity index 98% rename from lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/ContactProcessor.kt rename to lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactBuilder.kt index a07f0e2d..fe4c18e3 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/ContactProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactBuilder.kt @@ -23,7 +23,7 @@ import at.bitfire.synctools.mapping.contacts.builder.StructuredPostalBuilder import at.bitfire.synctools.mapping.contacts.builder.WebsiteBuilder import at.bitfire.synctools.storage.contacts.ContactsBatchOperation -class ContactProcessor { +class RawContactBuilder { private val dataRowBuilderFactories = mutableListOf>( EmailBuilder.Factory, 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 9b86e3ad..eac66d96 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,7 +17,7 @@ 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 @@ -44,8 +44,6 @@ open class AndroidContact( var eTag: String? = null - val processor = ContactProcessor() - /** * Creates a new instance, initialized with some metadata. Usually used to insert a contact to an address book. @@ -154,7 +152,8 @@ 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 rawContactBuilder = RawContactBuilder() + val typesToRemove = rawContactBuilder.builderMimeTypes() val sqlTypesToRemove = typesToRemove.joinToString(",") { mimeType -> DatabaseUtils.sqlEscapeString(mimeType) } @@ -208,7 +207,8 @@ open class AndroidContact( */ protected fun insertDataRows(batch: ContactsBatchOperation) { val contact = getContact() - processor.insertDataRows(dataSyncURI(), id, contact, batch, addressBook.readOnly) + val rawContactBuilder = RawContactBuilder() + rawContactBuilder.insertDataRows(dataSyncURI(), id, contact, batch, addressBook.readOnly) } From b65388b12373136b4b10acc6a26404dc8a67d207 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Fri, 22 May 2026 12:08:58 +0200 Subject: [PATCH 06/14] Rename `_contact` to `cachedContact` for clarity --- .../synctools/storage/contacts/AndroidContact.kt | 10 +++++----- .../synctools/storage/contacts/AndroidGroup.kt | 12 ++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) 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 eac66d96..2f85517b 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 @@ -68,7 +68,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. @@ -78,7 +78,7 @@ open class AndroidContact( * @throws RemoteException on contact provider errors */ fun getContact(): Contact { - _contact?.let { return it } + cachedContact?.let { return it } val id = requireNotNull(id) var iter: EntityIterator? = null @@ -99,7 +99,7 @@ open class AndroidContact( for (subValue in e.subValues) rawContactHandler.handleDataRow(subValue.values, contact) - _contact = contact + cachedContact = contact return contact } else @@ -112,7 +112,7 @@ open class AndroidContact( } fun setContact(newContact: Contact) { - _contact = newContact + cachedContact = newContact } @@ -222,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..dfa3eaee 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, _contact=$cachedContact)" } From ee3c6485bdacc271c506e267ba3051d2a63a6e89 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Fri, 22 May 2026 12:20:26 +0200 Subject: [PATCH 07/14] Minor changes --- .../at/bitfire/synctools/mapping/contacts/RawContactBuilder.kt | 2 -- .../at/bitfire/synctools/storage/contacts/AndroidContact.kt | 1 + .../at/bitfire/synctools/storage/contacts/AndroidGroup.kt | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) 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 index fe4c18e3..e14047ec 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactBuilder.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactBuilder.kt @@ -45,7 +45,6 @@ class RawContactBuilder { 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) @@ -53,7 +52,6 @@ class RawContactBuilder { } } - fun builderMimeTypes(): Set { val mimeTypes = mutableSetOf() for (factory in dataRowBuilderFactories) 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 2f85517b..13752f41 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 @@ -78,6 +78,7 @@ open class AndroidContact( * @throws RemoteException on contact provider errors */ fun getContact(): Contact { + // use cached version if available cachedContact?.let { return it } val id = requireNotNull(id) 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 dfa3eaee..8e9e69db 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 @@ -178,6 +178,6 @@ open class AndroidGroup( } override fun toString() = - "AndroidGroup(id=$id, fileName=$fileName, eTag=$eTag, _contact=$cachedContact)" + "AndroidGroup(id=$id, fileName=$fileName, eTag=$eTag, cachedCcontact=$cachedContact)" } From 2072a7df5f0064f35ad18332a5c94ca2f835db3e Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Fri, 22 May 2026 12:29:33 +0200 Subject: [PATCH 08/14] Move `testAddressCaretEncoding` to `ContactWriterTest` and `testBirthdayWithOffset` to `ContactReaderTest` --- .../storage/contacts/AndroidContactTest.kt | 57 ------------------- .../mapping/contacts/ContactReaderTest.kt | 28 +++++++++ .../mapping/contacts/ContactWriterTest.kt | 28 +++++++++ 3 files changed, 56 insertions(+), 57 deletions(-) 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 ed67b8e0..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 @@ -17,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 @@ -32,16 +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 { @@ -137,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() { @@ -202,33 +172,6 @@ class AndroidContactTest { AndroidContact(addressBook, values).getContact() } - @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\";;;;")) - } - private fun parseVCards(vCardStr: String): List = VCardParser().parse(StringReader(vCardStr)).map { vCard -> 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() { From ab25075f92877d44989d4c9ba4f1ea69cd7727ce Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Fri, 22 May 2026 12:57:33 +0200 Subject: [PATCH 09/14] Remove `RawContactHandlerTest` --- .../mapping/contacts/RawContactHandlerTest.kt | 95 ------------------- 1 file changed, 95 deletions(-) delete mode 100644 lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandlerTest.kt diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandlerTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandlerTest.kt deleted file mode 100644 index 83c6142a..00000000 --- a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandlerTest.kt +++ /dev/null @@ -1,95 +0,0 @@ -/* - * 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.Manifest -import android.accounts.Account -import android.content.ContentProviderClient -import android.content.EntityIterator -import android.provider.ContactsContract -import android.provider.ContactsContract.RawContacts -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.rule.GrantPermissionRule -import at.bitfire.synctools.storage.contacts.AndroidContact -import at.bitfire.synctools.storage.contacts.TestAddressBook -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 - -class RawContactHandlerTest { - - companion object { - @JvmField - @ClassRule - val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!! - - private val testAddressBookAccount = Account("RawContactHandlerTest", "at.bitfire.vcard4android") - - private lateinit var provider: ContentProviderClient - private lateinit var addressBook: TestAddressBook - - @BeforeClass - @JvmStatic - fun connect() { - val context = InstrumentationRegistry.getInstrumentation().context - provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!! - assertNotNull(provider) - addressBook = TestAddressBook(testAddressBookAccount, provider) - } - - @BeforeClass - @JvmStatic - fun disconnect() { - @Suppress("DEPRECATION") - provider.release() - } - } - - - @Test - fun testHandleRawContactAndDataRows() { - val vcard = Contact() - vcard.displayName = "RawContactHandler Test" - vcard.givenName = "Test" - vcard.familyName = "Contact" - - val contact = AndroidContact(addressBook, vcard, "test.vcf", "etag1") - contact.add() - - try { - val handler = RawContactHandler(addressBook.provider!!) - val entityUri = addressBook.syncAdapterURI(ContactsContract.RawContactsEntity.CONTENT_URI) - - var iter: EntityIterator? = null - try { - iter = RawContacts.newEntityIterator(addressBook.provider!!.query( - entityUri, null, RawContacts._ID + "=?", arrayOf(contact.id!!.toString()), null)) - - assertTrue(iter.hasNext()) - val e = iter.next() - - val fetched = Contact() - handler.handleRawContact(e.entityValues, fetched) - for (subValue in e.subValues) - handler.handleDataRow(subValue.values, fetched) - - assertEquals(vcard.displayName, fetched.displayName) - assertEquals(vcard.givenName, fetched.givenName) - assertEquals(vcard.familyName, fetched.familyName) - } finally { - iter?.close() - } - } finally { - contact.delete() - } - } - -} - From 1350f02ca5ae8169e4d38d37419de7b43155229f Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Fri, 22 May 2026 13:04:25 +0200 Subject: [PATCH 10/14] Fix typo --- .../at/bitfire/synctools/storage/contacts/AndroidGroup.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8e9e69db..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 @@ -178,6 +178,6 @@ open class AndroidGroup( } override fun toString() = - "AndroidGroup(id=$id, fileName=$fileName, eTag=$eTag, cachedCcontact=$cachedContact)" + "AndroidGroup(id=$id, fileName=$fileName, eTag=$eTag, cachedContact=$cachedContact)" } From 0c230480865d5a77237b369c5010ed496bf84ef8 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Fri, 22 May 2026 13:09:21 +0200 Subject: [PATCH 11/14] Allow `AndroidContact subclasses/users to specify their `RawContactBuilder` and `RawContactHandler` instances --- .../at/bitfire/synctools/storage/contacts/AndroidContact.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 13752f41..3e287b6a 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 @@ -25,7 +25,9 @@ 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 { @@ -88,7 +90,6 @@ open class AndroidContact( addressBook.syncAdapterURI(ContactsContract.RawContactsEntity.CONTENT_URI), null, RawContacts._ID + "=?", arrayOf(id.toString()), null)) - val rawContactHandler = RawContactHandler(addressBook.provider!!) if (iter.hasNext()) { val contact = Contact() @@ -153,7 +154,6 @@ 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 rawContactBuilder = RawContactBuilder() val typesToRemove = rawContactBuilder.builderMimeTypes() val sqlTypesToRemove = typesToRemove.joinToString(",") { mimeType -> DatabaseUtils.sqlEscapeString(mimeType) From 06a437563848c3ae1612a646d61ad580c43163c5 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Fri, 22 May 2026 13:21:16 +0200 Subject: [PATCH 12/14] Make `setContact` parameter nullable --- .../at/bitfire/synctools/storage/contacts/AndroidContact.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 3e287b6a..2e2500e8 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 @@ -1,7 +1,5 @@ /* - * 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 + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. */ package at.bitfire.synctools.storage.contacts @@ -113,7 +111,7 @@ open class AndroidContact( } } - fun setContact(newContact: Contact) { + fun setContact(newContact: Contact?) { cachedContact = newContact } From 1a8838508c6eed8d076ddba894a65d0254234149 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Fri, 22 May 2026 13:26:25 +0200 Subject: [PATCH 13/14] Revert mistaken copyright notice change --- .../at/bitfire/synctools/storage/contacts/AndroidContact.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 2e2500e8..ce7c15b5 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 @@ -1,5 +1,7 @@ /* - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + * 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.storage.contacts From fd324d8f5ac77dadf8de39efe006f4842ec309f8 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Fri, 22 May 2026 13:38:01 +0200 Subject: [PATCH 14/14] Use correct rawContactBuilder in insertDataRows --- .../at/bitfire/synctools/storage/contacts/AndroidContact.kt | 1 - 1 file changed, 1 deletion(-) 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 ce7c15b5..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 @@ -208,7 +208,6 @@ open class AndroidContact( */ protected fun insertDataRows(batch: ContactsBatchOperation) { val contact = getContact() - val rawContactBuilder = RawContactBuilder() rawContactBuilder.insertDataRows(dataSyncURI(), id, contact, batch, addressBook.readOnly) }