Skip to content
This repository was archived by the owner on May 26, 2026. It is now read-only.

Commit 1fb67fb

Browse files
authored
Support UnknownProperties and GroupMembership (#438)
* Move UnknownProperties and GroupMembership from synctools to davx5-ose * Don't initialize handlers/builders in davx5-ose anymore (everything is handled in synctools) * Add ...Contract to UnknownProperty and CachedGroupMembership definitions * Minor changes, revert mistaken copyright changes * Update copyright * Fix test * Minor changes * Simplify handler/builder creation * Add KDoc
1 parent ea61bf9 commit 1fb67fb

18 files changed

Lines changed: 645 additions & 18 deletions
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* This file is part of bitfireAT/synctools which is released under GPLv3.
3+
* Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details.
4+
* SPDX-License-Identifier: GPL-3.0-or-later
5+
*/
6+
7+
package at.bitfire.synctools.mapping.contacts.builder
8+
9+
import android.Manifest
10+
import android.accounts.Account
11+
import android.content.ContentProviderClient
12+
import android.net.Uri
13+
import android.provider.ContactsContract
14+
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
15+
import androidx.test.platform.app.InstrumentationRegistry
16+
import androidx.test.rule.GrantPermissionRule
17+
import at.bitfire.synctools.mapping.contacts.Contact
18+
import at.bitfire.synctools.storage.contacts.TestAddressBook
19+
import at.bitfire.synctools.vcard.GroupMethod
20+
import org.junit.AfterClass
21+
import org.junit.Assert.assertEquals
22+
import org.junit.BeforeClass
23+
import org.junit.ClassRule
24+
import org.junit.Test
25+
26+
class GroupMembershipBuilderTest {
27+
28+
@Test
29+
fun testCategories_GroupsAsCategories() {
30+
val addressBook = TestAddressBook(account, provider)
31+
val contact = Contact().apply {
32+
categories += "TEST GROUP"
33+
}
34+
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBook, GroupMethod.CATEGORIES, false).build().also { result ->
35+
assertEquals(1, result.size)
36+
assertEquals(GroupMembership.CONTENT_ITEM_TYPE, result[0].values[GroupMembership.MIMETYPE])
37+
assertEquals(addressBook.findOrCreateGroup("TEST GROUP"), result[0].values[GroupMembership.GROUP_ROW_ID])
38+
}
39+
}
40+
41+
@Test
42+
fun testCategories_GroupsAsVCards() {
43+
val addressBook = TestAddressBook(account, provider)
44+
val contact = Contact().apply {
45+
categories += "TEST GROUP"
46+
}
47+
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBook, GroupMethod.GROUP_VCARDS, false).build().also { result ->
48+
// group membership is constructed during post-processing
49+
assertEquals(0, result.size)
50+
}
51+
}
52+
53+
54+
companion object {
55+
56+
@JvmField
57+
@ClassRule
58+
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
59+
60+
val account = Account("GroupMembershipBuilderTest", "at.bitfire.vcard4android")
61+
62+
private lateinit var provider: ContentProviderClient
63+
64+
@BeforeClass
65+
@JvmStatic
66+
fun connect() {
67+
val context = InstrumentationRegistry.getInstrumentation().context
68+
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
69+
}
70+
71+
@AfterClass
72+
@JvmStatic
73+
fun disconnect() {
74+
provider.close()
75+
}
76+
77+
}
78+
79+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* This file is part of bitfireAT/synctools which is released under GPLv3.
3+
* Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details.
4+
* SPDX-License-Identifier: GPL-3.0-or-later
5+
*/
6+
7+
package at.bitfire.synctools.mapping.contacts.handler
8+
9+
import android.Manifest
10+
import android.accounts.Account
11+
import android.content.ContentProviderClient
12+
import android.content.ContentValues
13+
import android.provider.ContactsContract
14+
import androidx.test.platform.app.InstrumentationRegistry
15+
import androidx.test.rule.GrantPermissionRule
16+
import at.bitfire.synctools.mapping.contacts.Contact
17+
import at.bitfire.synctools.storage.contacts.AndroidContact
18+
import at.bitfire.synctools.storage.contacts.CachedGroupMembershipContract
19+
import at.bitfire.synctools.storage.contacts.TestAddressBook
20+
import at.bitfire.synctools.vcard.GroupMethod
21+
import org.junit.AfterClass
22+
import org.junit.Assert.assertArrayEquals
23+
import org.junit.BeforeClass
24+
import org.junit.ClassRule
25+
import org.junit.Test
26+
27+
class CachedGroupMembershipHandlerTest {
28+
29+
@Test
30+
fun testMembership() {
31+
val addressBook = TestAddressBook(account, provider)
32+
val contact = Contact()
33+
val androidContact = AndroidContact(addressBook, contact, null, null)
34+
CachedGroupMembershipHandler(androidContact, GroupMethod.GROUP_VCARDS).handle(ContentValues().apply {
35+
put(CachedGroupMembershipContract.GROUP_ID, 123456)
36+
put(CachedGroupMembershipContract.RAW_CONTACT_ID, 789)
37+
}, contact)
38+
assertArrayEquals(arrayOf(123456L), androidContact.cachedGroupMemberships.toArray())
39+
}
40+
41+
42+
companion object {
43+
44+
@JvmField
45+
@ClassRule
46+
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
47+
48+
val account = Account("CachedGroupMembershipHandlerTest", "at.bitfire.vcard4android")
49+
50+
private lateinit var provider: ContentProviderClient
51+
52+
@BeforeClass
53+
@JvmStatic
54+
fun connect() {
55+
val context = InstrumentationRegistry.getInstrumentation().context
56+
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
57+
}
58+
59+
@AfterClass
60+
@JvmStatic
61+
fun disconnect() {
62+
provider.close()
63+
}
64+
65+
}
66+
67+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
* This file is part of bitfireAT/synctools which is released under GPLv3.
3+
* Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details.
4+
* SPDX-License-Identifier: GPL-3.0-or-later
5+
*/
6+
7+
package at.bitfire.synctools.mapping.contacts.handler
8+
9+
import android.Manifest
10+
import android.accounts.Account
11+
import android.content.ContentProviderClient
12+
import android.content.ContentValues
13+
import android.provider.ContactsContract
14+
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
15+
import androidx.test.platform.app.InstrumentationRegistry
16+
import androidx.test.rule.GrantPermissionRule
17+
import at.bitfire.synctools.mapping.contacts.Contact
18+
import at.bitfire.synctools.storage.contacts.AndroidContact
19+
import at.bitfire.synctools.storage.contacts.CachedGroupMembershipContract
20+
import at.bitfire.synctools.storage.contacts.TestAddressBook
21+
import at.bitfire.synctools.vcard.GroupMethod
22+
import org.junit.AfterClass
23+
import org.junit.Assert.assertArrayEquals
24+
import org.junit.Assert.assertTrue
25+
import org.junit.BeforeClass
26+
import org.junit.ClassRule
27+
import org.junit.Test
28+
29+
class GroupMembershipHandlerTest {
30+
31+
@Test
32+
fun testMembership_GroupsAsCategories() {
33+
val addressBook = TestAddressBook(account, provider)
34+
val groupId = addressBook.findOrCreateGroup("TEST GROUP")
35+
36+
val contact = Contact()
37+
val androidContact = AndroidContact(addressBook, contact, null, null)
38+
GroupMembershipHandler(androidContact, GroupMethod.CATEGORIES).handle(ContentValues().apply {
39+
put(GroupMembership.GROUP_ROW_ID, groupId)
40+
put(CachedGroupMembershipContract.RAW_CONTACT_ID, -1)
41+
}, contact)
42+
assertArrayEquals(arrayOf(groupId), androidContact.groupMemberships.toArray())
43+
assertArrayEquals(arrayOf("TEST GROUP"), contact.categories.toArray())
44+
}
45+
46+
@Test
47+
fun testMembership_GroupsAsVCards() {
48+
val addressBook = TestAddressBook(account, provider)
49+
val contact = Contact()
50+
val androidContact = AndroidContact(addressBook, contact, null, null)
51+
GroupMembershipHandler(androidContact, GroupMethod.GROUP_VCARDS).handle(ContentValues().apply {
52+
put(GroupMembership.GROUP_ROW_ID, 12345L) // group doesn't have to really exist for GROUP_VCARDS
53+
put(CachedGroupMembershipContract.RAW_CONTACT_ID, -1)
54+
}, contact)
55+
assertArrayEquals(arrayOf(12345L), androidContact.groupMemberships.toArray())
56+
assertTrue(contact.categories.isEmpty())
57+
}
58+
59+
60+
companion object {
61+
62+
@JvmField
63+
@ClassRule
64+
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
65+
66+
val account = Account("GroupMembershipHandlerTest", "at.bitfire.vcard4android")
67+
68+
private lateinit var provider: ContentProviderClient
69+
70+
@BeforeClass
71+
@JvmStatic
72+
fun connect() {
73+
val context = InstrumentationRegistry.getInstrumentation().context
74+
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
75+
}
76+
77+
@AfterClass
78+
@JvmStatic
79+
fun disconnect() {
80+
provider.close()
81+
}
82+
83+
}
84+
85+
}

lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactBuilder.kt

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
package at.bitfire.synctools.mapping.contacts
88

99
import android.net.Uri
10-
import at.bitfire.synctools.mapping.contacts.builder.DataRowBuilder
1110
import at.bitfire.synctools.mapping.contacts.builder.EmailBuilder
1211
import at.bitfire.synctools.mapping.contacts.builder.EventBuilder
12+
import at.bitfire.synctools.mapping.contacts.builder.GroupMembershipBuilder
1313
import at.bitfire.synctools.mapping.contacts.builder.ImBuilder
1414
import at.bitfire.synctools.mapping.contacts.builder.NicknameBuilder
1515
import at.bitfire.synctools.mapping.contacts.builder.NoteBuilder
@@ -20,14 +20,17 @@ import at.bitfire.synctools.mapping.contacts.builder.RelationBuilder
2020
import at.bitfire.synctools.mapping.contacts.builder.SipAddressBuilder
2121
import at.bitfire.synctools.mapping.contacts.builder.StructuredNameBuilder
2222
import at.bitfire.synctools.mapping.contacts.builder.StructuredPostalBuilder
23+
import at.bitfire.synctools.mapping.contacts.builder.UnknownPropertiesBuilder
2324
import at.bitfire.synctools.mapping.contacts.builder.WebsiteBuilder
25+
import at.bitfire.synctools.storage.contacts.AndroidAddressBook
2426
import at.bitfire.synctools.storage.contacts.ContactsBatchOperation
2527

26-
class RawContactBuilder {
28+
class RawContactBuilder(addressBook: AndroidAddressBook<*, *>) {
2729

28-
private val dataRowBuilderFactories = mutableListOf<DataRowBuilder.Factory<*>>(
30+
private val dataRowBuilderFactories = mutableListOf(
2931
EmailBuilder.Factory,
3032
EventBuilder.Factory,
33+
GroupMembershipBuilder.Factory(addressBook, addressBook.groupMethod),
3134
ImBuilder.Factory,
3235
NicknameBuilder.Factory,
3336
NoteBuilder.Factory,
@@ -38,13 +41,10 @@ class RawContactBuilder {
3841
SipAddressBuilder.Factory,
3942
StructuredNameBuilder.Factory,
4043
StructuredPostalBuilder.Factory,
44+
UnknownPropertiesBuilder.Factory,
4145
WebsiteBuilder.Factory
4246
)
4347

44-
fun registerBuilderFactory(factory: DataRowBuilder.Factory<*>) {
45-
dataRowBuilderFactories += factory
46-
}
47-
4848
fun insertDataRows(dataRowUri: Uri, rawContactId: Long?, contact: Contact, batch: ContactsBatchOperation, readOnly: Boolean) {
4949
for (factory in dataRowBuilderFactories) {
5050
val builder = factory.newInstance(dataRowUri, rawContactId, contact, readOnly)

lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/RawContactHandler.kt

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@
66

77
package at.bitfire.synctools.mapping.contacts
88

9-
import android.content.ContentProviderClient
109
import android.content.ContentValues
1110
import android.provider.ContactsContract.RawContacts
11+
import at.bitfire.synctools.mapping.contacts.handler.CachedGroupMembershipHandler
1212
import at.bitfire.synctools.mapping.contacts.handler.DataRowHandler
1313
import at.bitfire.synctools.mapping.contacts.handler.EmailHandler
1414
import at.bitfire.synctools.mapping.contacts.handler.EventHandler
15+
import at.bitfire.synctools.mapping.contacts.handler.GroupMembershipHandler
1516
import at.bitfire.synctools.mapping.contacts.handler.ImHandler
1617
import at.bitfire.synctools.mapping.contacts.handler.NicknameHandler
1718
import at.bitfire.synctools.mapping.contacts.handler.NoteHandler
@@ -22,29 +23,33 @@ import at.bitfire.synctools.mapping.contacts.handler.RelationHandler
2223
import at.bitfire.synctools.mapping.contacts.handler.SipAddressHandler
2324
import at.bitfire.synctools.mapping.contacts.handler.StructuredNameHandler
2425
import at.bitfire.synctools.mapping.contacts.handler.StructuredPostalHandler
26+
import at.bitfire.synctools.mapping.contacts.handler.UnknownPropertiesHandler
2527
import at.bitfire.synctools.mapping.contacts.handler.WebsiteHandler
2628
import at.bitfire.synctools.storage.contacts.AndroidContact
2729
import java.util.logging.Level
2830
import java.util.logging.Logger
2931

3032
class RawContactHandler(
31-
provider: ContentProviderClient
33+
androidContact: AndroidContact
3234
) {
3335

3436
private val dataRowHandlers = mutableMapOf<String, MutableList<DataRowHandler>>()
3537
private val defaultDataRowHandlers = arrayOf(
38+
CachedGroupMembershipHandler(androidContact, androidContact.addressBook.groupMethod),
3639
EmailHandler,
3740
EventHandler,
41+
GroupMembershipHandler(androidContact, androidContact.addressBook.groupMethod),
3842
ImHandler,
3943
NicknameHandler,
4044
NoteHandler,
4145
OrganizationHandler,
4246
PhoneHandler,
43-
PhotoHandler(provider),
47+
PhotoHandler(androidContact.addressBook.provider!!),
4448
RelationHandler,
4549
SipAddressHandler,
4650
StructuredNameHandler,
4751
StructuredPostalHandler,
52+
UnknownPropertiesHandler,
4853
WebsiteHandler
4954
)
5055

@@ -53,7 +58,7 @@ class RawContactHandler(
5358
registerHandler(handler)
5459
}
5560

56-
fun registerHandler(handler: DataRowHandler) {
61+
private fun registerHandler(handler: DataRowHandler) {
5762
val mimeType = handler.forMimeType()
5863
val handlers = dataRowHandlers[mimeType] ?: run {
5964
val newList = mutableListOf<DataRowHandler>()
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* This file is part of bitfireAT/synctools which is released under GPLv3.
3+
* Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details.
4+
* SPDX-License-Identifier: GPL-3.0-or-later
5+
*/
6+
7+
package at.bitfire.synctools.mapping.contacts.builder
8+
9+
import android.net.Uri
10+
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
11+
import at.bitfire.synctools.mapping.contacts.Contact
12+
import at.bitfire.synctools.storage.BatchOperation
13+
import at.bitfire.synctools.storage.contacts.AndroidAddressBook
14+
import at.bitfire.synctools.vcard.GroupMethod
15+
import java.util.LinkedList
16+
17+
class GroupMembershipBuilder(
18+
dataRowUri: Uri,
19+
rawContactId: Long?,
20+
contact: Contact,
21+
val addressBook: AndroidAddressBook<*, *>,
22+
val groupMethod: GroupMethod,
23+
readOnly: Boolean
24+
) : DataRowBuilder(Factory.MIME_TYPE, dataRowUri, rawContactId, contact, readOnly) {
25+
26+
override fun build(): List<BatchOperation.CpoBuilder> {
27+
val result = LinkedList<BatchOperation.CpoBuilder>()
28+
29+
if (groupMethod == GroupMethod.CATEGORIES)
30+
for (category in contact.categories)
31+
result += newDataRow().withValue(GroupMembership.GROUP_ROW_ID, addressBook.findOrCreateGroup(category))
32+
else {
33+
// GroupMethod.GROUP_VCARDS -> memberships are handled by AndroidGroups (and not by the members = AndroidContacts, which we are processing here)
34+
// TODO: CATEGORIES <-> unknown properties
35+
}
36+
37+
return result
38+
}
39+
40+
41+
class Factory(val addressBook: AndroidAddressBook<*, *>, val groupMethod: GroupMethod) : DataRowBuilder.Factory<GroupMembershipBuilder> {
42+
companion object {
43+
const val MIME_TYPE = GroupMembership.CONTENT_ITEM_TYPE
44+
}
45+
46+
override fun mimeType() = MIME_TYPE
47+
override fun newInstance(dataRowUri: Uri, rawContactId: Long?, contact: Contact, readOnly: Boolean) =
48+
GroupMembershipBuilder(dataRowUri, rawContactId, contact, addressBook, groupMethod, readOnly)
49+
}
50+
51+
}

0 commit comments

Comments
 (0)