diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/builder/GroupMembershipBuilderTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/builder/GroupMembershipBuilderTest.kt new file mode 100644 index 00000000..d1dd5e28 --- /dev/null +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/builder/GroupMembershipBuilderTest.kt @@ -0,0 +1,79 @@ +/* + * 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.builder + +import android.Manifest +import android.accounts.Account +import android.content.ContentProviderClient +import android.net.Uri +import android.provider.ContactsContract +import android.provider.ContactsContract.CommonDataKinds.GroupMembership +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import at.bitfire.synctools.mapping.contacts.Contact +import at.bitfire.synctools.storage.contacts.TestAddressBook +import at.bitfire.synctools.vcard.GroupMethod +import org.junit.AfterClass +import org.junit.Assert.assertEquals +import org.junit.BeforeClass +import org.junit.ClassRule +import org.junit.Test + +class GroupMembershipBuilderTest { + + @Test + fun testCategories_GroupsAsCategories() { + val addressBook = TestAddressBook(account, provider) + val contact = Contact().apply { + categories += "TEST GROUP" + } + GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBook, GroupMethod.CATEGORIES, false).build().also { result -> + assertEquals(1, result.size) + assertEquals(GroupMembership.CONTENT_ITEM_TYPE, result[0].values[GroupMembership.MIMETYPE]) + assertEquals(addressBook.findOrCreateGroup("TEST GROUP"), result[0].values[GroupMembership.GROUP_ROW_ID]) + } + } + + @Test + fun testCategories_GroupsAsVCards() { + val addressBook = TestAddressBook(account, provider) + val contact = Contact().apply { + categories += "TEST GROUP" + } + GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBook, GroupMethod.GROUP_VCARDS, false).build().also { result -> + // group membership is constructed during post-processing + assertEquals(0, result.size) + } + } + + + companion object { + + @JvmField + @ClassRule + val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!! + + val account = Account("GroupMembershipBuilderTest", "at.bitfire.vcard4android") + + private lateinit var provider: ContentProviderClient + + @BeforeClass + @JvmStatic + fun connect() { + val context = InstrumentationRegistry.getInstrumentation().context + provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!! + } + + @AfterClass + @JvmStatic + fun disconnect() { + provider.close() + } + + } + +} diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/handler/CachedGroupMembershipHandlerTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/handler/CachedGroupMembershipHandlerTest.kt new file mode 100644 index 00000000..57f9c881 --- /dev/null +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/handler/CachedGroupMembershipHandlerTest.kt @@ -0,0 +1,67 @@ +/* + * 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.handler + +import android.Manifest +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentValues +import android.provider.ContactsContract +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import at.bitfire.synctools.mapping.contacts.Contact +import at.bitfire.synctools.storage.contacts.AndroidContact +import at.bitfire.synctools.storage.contacts.CachedGroupMembershipContract +import at.bitfire.synctools.storage.contacts.TestAddressBook +import at.bitfire.synctools.vcard.GroupMethod +import org.junit.AfterClass +import org.junit.Assert.assertArrayEquals +import org.junit.BeforeClass +import org.junit.ClassRule +import org.junit.Test + +class CachedGroupMembershipHandlerTest { + + @Test + fun testMembership() { + val addressBook = TestAddressBook(account, provider) + val contact = Contact() + val androidContact = AndroidContact(addressBook, contact, null, null) + CachedGroupMembershipHandler(androidContact, GroupMethod.GROUP_VCARDS).handle(ContentValues().apply { + put(CachedGroupMembershipContract.GROUP_ID, 123456) + put(CachedGroupMembershipContract.RAW_CONTACT_ID, 789) + }, contact) + assertArrayEquals(arrayOf(123456L), androidContact.cachedGroupMemberships.toArray()) + } + + + companion object { + + @JvmField + @ClassRule + val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!! + + val account = Account("CachedGroupMembershipHandlerTest", "at.bitfire.vcard4android") + + private lateinit var provider: ContentProviderClient + + @BeforeClass + @JvmStatic + fun connect() { + val context = InstrumentationRegistry.getInstrumentation().context + provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!! + } + + @AfterClass + @JvmStatic + fun disconnect() { + provider.close() + } + + } + +} diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/handler/GroupMembershipHandlerTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/handler/GroupMembershipHandlerTest.kt new file mode 100644 index 00000000..4f04195a --- /dev/null +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/contacts/handler/GroupMembershipHandlerTest.kt @@ -0,0 +1,85 @@ +/* + * 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.handler + +import android.Manifest +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentValues +import android.provider.ContactsContract +import android.provider.ContactsContract.CommonDataKinds.GroupMembership +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import at.bitfire.synctools.mapping.contacts.Contact +import at.bitfire.synctools.storage.contacts.AndroidContact +import at.bitfire.synctools.storage.contacts.CachedGroupMembershipContract +import at.bitfire.synctools.storage.contacts.TestAddressBook +import at.bitfire.synctools.vcard.GroupMethod +import org.junit.AfterClass +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertTrue +import org.junit.BeforeClass +import org.junit.ClassRule +import org.junit.Test + +class GroupMembershipHandlerTest { + + @Test + fun testMembership_GroupsAsCategories() { + val addressBook = TestAddressBook(account, provider) + val groupId = addressBook.findOrCreateGroup("TEST GROUP") + + val contact = Contact() + val androidContact = AndroidContact(addressBook, contact, null, null) + GroupMembershipHandler(androidContact, GroupMethod.CATEGORIES).handle(ContentValues().apply { + put(GroupMembership.GROUP_ROW_ID, groupId) + put(CachedGroupMembershipContract.RAW_CONTACT_ID, -1) + }, contact) + assertArrayEquals(arrayOf(groupId), androidContact.groupMemberships.toArray()) + assertArrayEquals(arrayOf("TEST GROUP"), contact.categories.toArray()) + } + + @Test + fun testMembership_GroupsAsVCards() { + val addressBook = TestAddressBook(account, provider) + val contact = Contact() + val androidContact = AndroidContact(addressBook, contact, null, null) + GroupMembershipHandler(androidContact, GroupMethod.GROUP_VCARDS).handle(ContentValues().apply { + put(GroupMembership.GROUP_ROW_ID, 12345L) // group doesn't have to really exist for GROUP_VCARDS + put(CachedGroupMembershipContract.RAW_CONTACT_ID, -1) + }, contact) + assertArrayEquals(arrayOf(12345L), androidContact.groupMemberships.toArray()) + assertTrue(contact.categories.isEmpty()) + } + + + companion object { + + @JvmField + @ClassRule + val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!! + + val account = Account("GroupMembershipHandlerTest", "at.bitfire.vcard4android") + + private lateinit var provider: ContentProviderClient + + @BeforeClass + @JvmStatic + fun connect() { + val context = InstrumentationRegistry.getInstrumentation().context + provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!! + } + + @AfterClass + @JvmStatic + fun disconnect() { + provider.close() + } + + } + +} 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 e14047ec..d45caa90 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 @@ -7,9 +7,9 @@ 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.GroupMembershipBuilder import at.bitfire.synctools.mapping.contacts.builder.ImBuilder import at.bitfire.synctools.mapping.contacts.builder.NicknameBuilder import at.bitfire.synctools.mapping.contacts.builder.NoteBuilder @@ -20,14 +20,17 @@ 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.UnknownPropertiesBuilder import at.bitfire.synctools.mapping.contacts.builder.WebsiteBuilder +import at.bitfire.synctools.storage.contacts.AndroidAddressBook import at.bitfire.synctools.storage.contacts.ContactsBatchOperation -class RawContactBuilder { +class RawContactBuilder(addressBook: AndroidAddressBook<*, *>) { - private val dataRowBuilderFactories = mutableListOf>( + private val dataRowBuilderFactories = mutableListOf( EmailBuilder.Factory, EventBuilder.Factory, + GroupMembershipBuilder.Factory(addressBook, addressBook.groupMethod), ImBuilder.Factory, NicknameBuilder.Factory, NoteBuilder.Factory, @@ -38,13 +41,10 @@ class RawContactBuilder { SipAddressBuilder.Factory, StructuredNameBuilder.Factory, StructuredPostalBuilder.Factory, + UnknownPropertiesBuilder.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) 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 6dfe6df8..2bcb1178 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 @@ -6,12 +6,13 @@ package at.bitfire.synctools.mapping.contacts -import android.content.ContentProviderClient import android.content.ContentValues import android.provider.ContactsContract.RawContacts +import at.bitfire.synctools.mapping.contacts.handler.CachedGroupMembershipHandler 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.GroupMembershipHandler import at.bitfire.synctools.mapping.contacts.handler.ImHandler import at.bitfire.synctools.mapping.contacts.handler.NicknameHandler import at.bitfire.synctools.mapping.contacts.handler.NoteHandler @@ -22,29 +23,33 @@ 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.UnknownPropertiesHandler import at.bitfire.synctools.mapping.contacts.handler.WebsiteHandler import at.bitfire.synctools.storage.contacts.AndroidContact import java.util.logging.Level import java.util.logging.Logger class RawContactHandler( - provider: ContentProviderClient + androidContact: AndroidContact ) { private val dataRowHandlers = mutableMapOf>() private val defaultDataRowHandlers = arrayOf( + CachedGroupMembershipHandler(androidContact, androidContact.addressBook.groupMethod), EmailHandler, EventHandler, + GroupMembershipHandler(androidContact, androidContact.addressBook.groupMethod), ImHandler, NicknameHandler, NoteHandler, OrganizationHandler, PhoneHandler, - PhotoHandler(provider), + PhotoHandler(androidContact.addressBook.provider!!), RelationHandler, SipAddressHandler, StructuredNameHandler, StructuredPostalHandler, + UnknownPropertiesHandler, WebsiteHandler ) @@ -53,7 +58,7 @@ class RawContactHandler( registerHandler(handler) } - fun registerHandler(handler: DataRowHandler) { + private fun registerHandler(handler: DataRowHandler) { val mimeType = handler.forMimeType() val handlers = dataRowHandlers[mimeType] ?: run { val newList = mutableListOf() diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/builder/GroupMembershipBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/builder/GroupMembershipBuilder.kt new file mode 100644 index 00000000..17118da7 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/builder/GroupMembershipBuilder.kt @@ -0,0 +1,51 @@ +/* + * 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.builder + +import android.net.Uri +import android.provider.ContactsContract.CommonDataKinds.GroupMembership +import at.bitfire.synctools.mapping.contacts.Contact +import at.bitfire.synctools.storage.BatchOperation +import at.bitfire.synctools.storage.contacts.AndroidAddressBook +import at.bitfire.synctools.vcard.GroupMethod +import java.util.LinkedList + +class GroupMembershipBuilder( + dataRowUri: Uri, + rawContactId: Long?, + contact: Contact, + val addressBook: AndroidAddressBook<*, *>, + val groupMethod: GroupMethod, + readOnly: Boolean +) : DataRowBuilder(Factory.MIME_TYPE, dataRowUri, rawContactId, contact, readOnly) { + + override fun build(): List { + val result = LinkedList() + + if (groupMethod == GroupMethod.CATEGORIES) + for (category in contact.categories) + result += newDataRow().withValue(GroupMembership.GROUP_ROW_ID, addressBook.findOrCreateGroup(category)) + else { + // GroupMethod.GROUP_VCARDS -> memberships are handled by AndroidGroups (and not by the members = AndroidContacts, which we are processing here) + // TODO: CATEGORIES <-> unknown properties + } + + return result + } + + + class Factory(val addressBook: AndroidAddressBook<*, *>, val groupMethod: GroupMethod) : DataRowBuilder.Factory { + companion object { + const val MIME_TYPE = GroupMembership.CONTENT_ITEM_TYPE + } + + override fun mimeType() = MIME_TYPE + override fun newInstance(dataRowUri: Uri, rawContactId: Long?, contact: Contact, readOnly: Boolean) = + GroupMembershipBuilder(dataRowUri, rawContactId, contact, addressBook, groupMethod, readOnly) + } + +} diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/builder/UnknownPropertiesBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/builder/UnknownPropertiesBuilder.kt new file mode 100644 index 00000000..4e175242 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/builder/UnknownPropertiesBuilder.kt @@ -0,0 +1,33 @@ +/* + * 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.builder + +import android.net.Uri +import at.bitfire.synctools.mapping.contacts.Contact +import at.bitfire.synctools.storage.BatchOperation +import at.bitfire.synctools.storage.contacts.UnknownPropertyContract +import java.util.LinkedList + +class UnknownPropertiesBuilder(dataRowUri: Uri, rawContactId: Long?, contact: Contact, readOnly: Boolean) : + DataRowBuilder(Factory.mimeType(), dataRowUri, rawContactId, contact, readOnly) { + + override fun build(): List { + val result = LinkedList() + contact.unknownProperties?.let { unknownProperties -> + result += newDataRow().withValue(UnknownPropertyContract.UNKNOWN_PROPERTIES, unknownProperties) + } + return result + } + + + object Factory : DataRowBuilder.Factory { + override fun mimeType() = UnknownPropertyContract.CONTENT_ITEM_TYPE + override fun newInstance(dataRowUri: Uri, rawContactId: Long?, contact: Contact, readOnly: Boolean) = + UnknownPropertiesBuilder(dataRowUri, rawContactId, contact, readOnly) + } + +} diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/handler/CachedGroupMembershipHandler.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/handler/CachedGroupMembershipHandler.kt new file mode 100644 index 00000000..1bd2f423 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/handler/CachedGroupMembershipHandler.kt @@ -0,0 +1,32 @@ +/* + * 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.handler + +import android.content.ContentValues +import at.bitfire.synctools.mapping.contacts.Contact +import at.bitfire.synctools.storage.contacts.AndroidContact +import at.bitfire.synctools.storage.contacts.CachedGroupMembershipContract +import at.bitfire.synctools.vcard.GroupMethod + +class CachedGroupMembershipHandler( + val androidContact: AndroidContact, + val groupMethod: GroupMethod +) : DataRowHandler() { + + override fun forMimeType() = CachedGroupMembershipContract.CONTENT_ITEM_TYPE + + override fun handle(values: ContentValues, contact: Contact) { + super.handle(values, contact) + + if (groupMethod == GroupMethod.GROUP_VCARDS) { + val groupId = values.getAsLong(CachedGroupMembershipContract.GROUP_ID) ?: return + androidContact.cachedGroupMemberships += groupId + } else + logger.warning("Ignoring cached group membership for group method CATEGORIES") + } + +} diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/handler/GroupMembershipHandler.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/handler/GroupMembershipHandler.kt new file mode 100644 index 00000000..f2396ddf --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/handler/GroupMembershipHandler.kt @@ -0,0 +1,43 @@ +/* + * 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.handler + +import android.content.ContentValues +import android.provider.ContactsContract.CommonDataKinds.GroupMembership +import at.bitfire.synctools.mapping.contacts.Contact +import at.bitfire.synctools.storage.contacts.AndroidContact +import at.bitfire.synctools.util.Utils.trimToNull +import at.bitfire.synctools.vcard.GroupMethod +import java.io.FileNotFoundException + +class GroupMembershipHandler( + val androidContact: AndroidContact, + val groupMethod: GroupMethod +) : DataRowHandler() { + + override fun forMimeType() = GroupMembership.CONTENT_ITEM_TYPE + + override fun handle(values: ContentValues, contact: Contact) { + super.handle(values, contact) + + val groupId = values.getAsLong(GroupMembership.GROUP_ROW_ID) ?: return + androidContact.groupMemberships += groupId + + if (groupMethod == GroupMethod.CATEGORIES) { + try { + val group = androidContact.addressBook.findGroupById(groupId) + group.getContact().displayName.trimToNull()?.let { groupName -> + logger.fine("Adding membership in group $groupName as category") + contact.categories.add(groupName) + } + } catch (ignored: FileNotFoundException) { + logger.warning("Contact is member in group $groupId which doesn't exist anymore") + } + } + } + +} diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/handler/UnknownPropertiesHandler.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/handler/UnknownPropertiesHandler.kt new file mode 100644 index 00000000..0138a5ac --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/handler/UnknownPropertiesHandler.kt @@ -0,0 +1,23 @@ +/* + * 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.handler + +import android.content.ContentValues +import at.bitfire.synctools.mapping.contacts.Contact +import at.bitfire.synctools.storage.contacts.UnknownPropertyContract + +object UnknownPropertiesHandler : DataRowHandler() { + + override fun forMimeType() = UnknownPropertyContract.CONTENT_ITEM_TYPE + + override fun handle(values: ContentValues, contact: Contact) { + super.handle(values, contact) + + contact.unknownProperties = values.getAsString(UnknownPropertyContract.UNKNOWN_PROPERTIES) + } + +} diff --git a/lib/src/main/kotlin/at/bitfire/synctools/storage/contacts/AndroidAddressBook.kt b/lib/src/main/kotlin/at/bitfire/synctools/storage/contacts/AndroidAddressBook.kt index 4bab217d..f21b904e 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/storage/contacts/AndroidAddressBook.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/storage/contacts/AndroidAddressBook.kt @@ -8,12 +8,16 @@ package at.bitfire.synctools.storage.contacts import android.accounts.Account import android.content.ContentProviderClient +import android.content.ContentUris import android.content.ContentValues import android.net.Uri +import android.os.RemoteException import android.provider.ContactsContract import android.provider.ContactsContract.Groups import android.provider.ContactsContract.RawContacts +import androidx.core.content.contentValuesOf import at.bitfire.synctools.storage.toContentValues +import at.bitfire.synctools.vcard.GroupMethod import java.io.FileNotFoundException import java.util.LinkedList @@ -25,6 +29,7 @@ open class AndroidAddressBook( ) { open var readOnly: Boolean = false + open val groupMethod: GroupMethod = GroupMethod.GROUP_VCARDS var settings: ContentValues /** @@ -118,6 +123,21 @@ open class AndroidAddressBook( fun findGroupById(id: Long) = queryGroups("${Groups._ID}=?", arrayOf(id.toString())).firstOrNull() ?: throw FileNotFoundException() + fun findOrCreateGroup(title: String): Long { + provider!!.query( + syncAdapterURI(Groups.CONTENT_URI), arrayOf(Groups._ID), + "${Groups.TITLE}=?", arrayOf(title), null + )?.use { cursor -> + if (cursor.moveToNext()) + return cursor.getLong(0) + } + + val values = contentValuesOf(Groups.TITLE to title) + val uri = provider!!.insert(syncAdapterURI(Groups.CONTENT_URI), values) + ?: throw RemoteException("Couldn't create contact group") + return ContentUris.parseId(uri) + } + // helpers 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 af6e2c70..67201778 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 @@ -13,6 +13,7 @@ import android.database.DatabaseUtils import android.net.Uri import android.os.RemoteException import android.provider.ContactsContract +import android.provider.ContactsContract.CommonDataKinds.GroupMembership import android.provider.ContactsContract.RawContacts import android.provider.ContactsContract.RawContacts.Data import androidx.annotation.CallSuper @@ -25,9 +26,7 @@ import at.bitfire.synctools.storage.LocalStorageException import java.io.FileNotFoundException open class AndroidContact( - open val addressBook: AndroidAddressBook, - protected open val rawContactBuilder: RawContactBuilder = RawContactBuilder(), - protected open val rawContactHandler: RawContactHandler = RawContactHandler(addressBook.provider!!) + open val addressBook: AndroidAddressBook ) { companion object { @@ -46,6 +45,29 @@ open class AndroidContact( var eTag: String? = null + /** + * IDs of groups this contact's cached group membership rows belong to. + * Only filled after [getContact] has been called. + * + * Used to detect which groups have become dirty when a contact's memberships change. + * See [CachedGroupMembershipContract] for details. + */ + val cachedGroupMemberships = HashSet() + + /** + * IDs of groups this contact is currently a member of. + * Only filled after [getContact] has been called. + */ + val groupMemberships = HashSet() + + private val rawContactHandler: RawContactHandler by lazy { + RawContactHandler(this) + } + + private val rawContactBuilder: RawContactBuilder by lazy { + RawContactBuilder(addressBook) + } + /** * Creates a new instance, initialized with some metadata. Usually used to insert a contact to an address book. @@ -212,6 +234,60 @@ open class AndroidContact( } + // group membership management + + fun addToGroup(batch: ContactsBatchOperation, groupID: Long) { + batch += BatchOperation.CpoBuilder + .newInsert(dataSyncURI()) + .withValue(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE) + .withValue(GroupMembership.RAW_CONTACT_ID, id!!) + .withValue(GroupMembership.GROUP_ROW_ID, groupID) + groupMemberships += groupID + + batch += BatchOperation.CpoBuilder + .newInsert(dataSyncURI()) + .withValue(CachedGroupMembershipContract.MIMETYPE, CachedGroupMembershipContract.CONTENT_ITEM_TYPE) + .withValue(CachedGroupMembershipContract.RAW_CONTACT_ID, id) + .withValue(CachedGroupMembershipContract.GROUP_ID, groupID) + cachedGroupMemberships += groupID + } + + fun removeGroupMemberships(batch: BatchOperation) { + batch += BatchOperation.CpoBuilder + .newDelete(dataSyncURI()) + .withSelection( + "${Data.RAW_CONTACT_ID}=? AND ${Data.MIMETYPE} IN (?,?)", + arrayOf(id!!.toString(), GroupMembership.CONTENT_ITEM_TYPE, CachedGroupMembershipContract.CONTENT_ITEM_TYPE) + ) + groupMemberships.clear() + cachedGroupMemberships.clear() + } + + /** + * Returns the IDs of all groups the contact was member of (cached memberships). + * Cached memberships are kept in sync with memberships by DAVx5 and are used to determine + * whether a membership has been deleted/added when a raw contact is dirty. + * @return set of [GroupMembership.GROUP_ROW_ID] (may be empty) + * @throws FileNotFoundException if the current contact can't be found + * @throws RemoteException on contacts provider errors + */ + fun getCachedGroupMemberships(): Set { + getContact() + return cachedGroupMemberships + } + + /** + * Returns the IDs of all groups the contact is member of. + * @return set of [GroupMembership.GROUP_ROW_ID]s (may be empty) + * @throws FileNotFoundException if the current contact can't be found + * @throws RemoteException on contacts provider errors + */ + fun getGroupMemberships(): Set { + getContact() + return groupMemberships + } + + // helpers fun rawContactSyncURI(): Uri { diff --git a/lib/src/main/kotlin/at/bitfire/synctools/storage/contacts/CachedGroupMembership.kt b/lib/src/main/kotlin/at/bitfire/synctools/storage/contacts/CachedGroupMembershipContract.kt similarity index 72% rename from lib/src/main/kotlin/at/bitfire/synctools/storage/contacts/CachedGroupMembership.kt rename to lib/src/main/kotlin/at/bitfire/synctools/storage/contacts/CachedGroupMembershipContract.kt index 4ba95bfe..06940fee 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/storage/contacts/CachedGroupMembership.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/storage/contacts/CachedGroupMembershipContract.kt @@ -7,6 +7,7 @@ package at.bitfire.synctools.storage.contacts import android.provider.ContactsContract.RawContacts.Data +import at.bitfire.synctools.storage.contacts.CachedGroupMembershipContract.MIMETYPE /** * Represents a "cached group membership" row. Cached group memberships exist only @@ -22,12 +23,18 @@ import android.provider.ContactsContract.RawContacts.Data * * Cached group memberships must not be used for anything else that detecting dirty groups. */ -object CachedGroupMembership { +object CachedGroupMembershipContract { + /** Column name for the MIME type of the data row. Type: [String] */ + const val MIMETYPE = Data.MIMETYPE + + /** MIME type of cached group membership data rows. Stored in [MIMETYPE]. */ const val CONTENT_ITEM_TYPE = "x.davdroid/cached-group-membership" - const val MIMETYPE = Data.MIMETYPE + /** Column name for the ID of the raw contact this cached membership belongs to. Type: [Long] */ const val RAW_CONTACT_ID = Data.RAW_CONTACT_ID + + /** Column name for the ID of the group this cached membership refers to. Type: [Long] */ const val GROUP_ID = Data.DATA1 } diff --git a/lib/src/main/kotlin/at/bitfire/synctools/storage/contacts/UnknownPropertyContract.kt b/lib/src/main/kotlin/at/bitfire/synctools/storage/contacts/UnknownPropertyContract.kt new file mode 100644 index 00000000..50b00da3 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/storage/contacts/UnknownPropertyContract.kt @@ -0,0 +1,25 @@ +/* + * 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 + +import android.provider.ContactsContract.RawContacts + +object UnknownPropertyContract { + + /** Column name for the MIME type of the data row. Type: [String] */ + const val MIMETYPE = RawContacts.Data.MIMETYPE + + /** MIME type of unknown-property data rows. Stored in [at.bitfire.synctools.storage.contacts.UnknownPropertyContract.MIMETYPE]. */ + const val CONTENT_ITEM_TYPE = "x.davdroid/unknown-properties" + + /** Column name for the ID of the raw contact this row belongs to. Type: [Long] */ + const val RAW_CONTACT_ID = RawContacts.Data.RAW_CONTACT_ID + + /** Column name for the serialized unknown vCard properties. Type: [String] */ + const val UNKNOWN_PROPERTIES = RawContacts.Data.DATA1 + +} diff --git a/lib/src/main/kotlin/at/bitfire/synctools/util/SensitiveString.kt b/lib/src/main/kotlin/at/bitfire/synctools/util/SensitiveString.kt index 7e34fcfd..9917d3cb 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/util/SensitiveString.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/util/SensitiveString.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.util diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/contacts/builder/UnknownPropertiesBuilderTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/contacts/builder/UnknownPropertiesBuilderTest.kt new file mode 100644 index 00000000..da0f1f88 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/contacts/builder/UnknownPropertiesBuilderTest.kt @@ -0,0 +1,38 @@ +/* + * 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.builder + +import android.net.Uri +import at.bitfire.synctools.mapping.contacts.Contact +import at.bitfire.synctools.storage.contacts.UnknownPropertyContract +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class UnknownPropertiesBuilderTest { + + @Test + fun testUnknownProperties_None() { + UnknownPropertiesBuilder(Uri.EMPTY, null, Contact(), false).build().also { result -> + assertEquals(0, result.size) + } + } + + @Test + fun testUnknownProperties_Properties() { + UnknownPropertiesBuilder(Uri.EMPTY, null, Contact().apply { + unknownProperties = "X-TEST:12345" + }, false).build().also { result -> + assertEquals(1, result.size) + assertEquals(UnknownPropertyContract.CONTENT_ITEM_TYPE, result[0].values[UnknownPropertyContract.MIMETYPE]) + assertEquals("X-TEST:12345", result[0].values[UnknownPropertyContract.UNKNOWN_PROPERTIES]) + } + } + +} diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/contacts/handler/UnknownPropertiesHandlerTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/contacts/handler/UnknownPropertiesHandlerTest.kt new file mode 100644 index 00000000..8405d298 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/contacts/handler/UnknownPropertiesHandlerTest.kt @@ -0,0 +1,39 @@ +/* + * 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.handler + +import android.content.ContentValues +import at.bitfire.synctools.mapping.contacts.Contact +import at.bitfire.synctools.storage.contacts.UnknownPropertyContract +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class UnknownPropertiesHandlerTest { + + @Test + fun testUnknownProperties_Empty() { + val contact = Contact() + UnknownPropertiesHandler.handle(ContentValues().apply { + putNull(UnknownPropertyContract.UNKNOWN_PROPERTIES) + }, contact) + assertNull(contact.unknownProperties) + } + + @Test + fun testUnknownProperties_Values() { + val contact = Contact() + UnknownPropertiesHandler.handle(ContentValues().apply { + put(UnknownPropertyContract.UNKNOWN_PROPERTIES, "X-TEST:12345") + }, contact) + assertEquals("X-TEST:12345", contact.unknownProperties) + } + +} diff --git a/lib/src/test/kotlin/at/bitfire/synctools/util/SensitiveStringTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/util/SensitiveStringTest.kt index d66e2249..18b93a8d 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/util/SensitiveStringTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/util/SensitiveStringTest.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.util