diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalAddressBookStoreTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalAddressBookStoreTest.kt new file mode 100644 index 000000000..6907d0cec --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalAddressBookStoreTest.kt @@ -0,0 +1,146 @@ +package at.bitfire.davdroid.resource + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.Context +import android.provider.ContactsContract +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.Service +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.SpyK +import io.mockk.junit4.MockKRule +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.runs +import io.mockk.verify +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class LocalAddressBookStoreTest { + + @get:Rule + val mockkRule = MockKRule(this) + + val context: Context = mockk(relaxed = true) { + every { getString(R.string.account_type_address_book) } returns "com.bitfire.davdroid.addressbook" + } +// val account = Account("MrRobert@example.com", "com.bitfire.davdroid.addressbook") + val account: Account = mockk(relaxed = true) { +// every { name } returns "MrRobert@example.com" +// every { type } returns "com.bitfire.davdroid.addressbook" + } + val provider = mockk(relaxed = true) + val addressBook: LocalAddressBook = mockk(relaxed = true) { + every { updateSyncFrameworkSettings() } just runs + every { addressBookAccount } returns account + every { settings } returns LocalAddressBookStore.contactsProviderSettings + } + + @SpyK + @InjectMockKs + var localAddressBookStore = LocalAddressBookStore( + collectionRepository = mockk(relaxed = true), + context = context, + localAddressBookFactory = mockk(relaxed = true) { + every { create(account, provider) } returns addressBook + }, + logger = mockk(relaxed = true), + serviceRepository = mockk(relaxed = true) { + every { get(any()) } returns null + every { get(200) } returns mockk { + every { accountName } returns "MrRobert@example.com" + } + }, + settings = mockk(relaxed = true) + ) + + + @Test + fun test_accountName_missingService() { + val collection = mockk { + every { id } returns 42 + every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl() + every { displayName } returns null + every { serviceId } returns 404 + } + assertEquals("funnyfriends #42", localAddressBookStore.accountName(collection)) + } + + @Test + fun test_accountName_missingDisplayName() { + val collection = mockk { + every { id } returns 42 + every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl() + every { displayName } returns null + every { serviceId } returns 200 + } + val accountName = localAddressBookStore.accountName(collection) + assertEquals("funnyfriends (MrRobert@example.com) #42", accountName) + } + + @Test + fun test_accountName_missingDisplayNameAndService() { + val collection = mockk(relaxed = true) { + every { id } returns 1 + every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl() + every { displayName } returns null + every { serviceId } returns 404 // missing service + } + assertEquals("funnyfriends #1", localAddressBookStore.accountName(collection)) + } + + + @Test + fun test_create_createAccountReturnsNull() { + val collection = mockk(relaxed = true) { + every { id } returns 1 + every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl() + } + every { localAddressBookStore.createAccount(any(), any(), any()) } returns null + assertEquals(null, localAddressBookStore.create(provider, collection)) + } + + @Test + fun test_create_createAccountReturnsAccount() { + val collection = mockk(relaxed = true) { + every { id } returns 1 + every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl() + } + every { localAddressBookStore.createAccount(any(), any(), any()) } returns account + every { addressBook.readOnly } returns true + val addrBook = localAddressBookStore.create(provider, collection)!! + + verify(exactly = 1) { addressBook.updateSyncFrameworkSettings() } + assertEquals(account, addrBook.addressBookAccount) + assertEquals(LocalAddressBookStore.contactsProviderSettings, addrBook.settings) + assertEquals(true, addrBook.readOnly) + + every { addressBook.readOnly } returns false + val addrBook2 = localAddressBookStore.create(provider, collection)!! + assertEquals(false, addrBook2.readOnly) + } + + + /** + * Tests the calculation of read only state is correct + */ + @Test + fun test_shouldBeReadOnly() { + val collectionReadOnly = mockk { every { readOnly() } returns true } + assertTrue(LocalAddressBookStore.shouldBeReadOnly(collectionReadOnly, false)) + assertTrue(LocalAddressBookStore.shouldBeReadOnly(collectionReadOnly, true)) + + val collectionNotReadOnly = mockk { every { readOnly() } returns false } + assertFalse(LocalAddressBookStore.shouldBeReadOnly(collectionNotReadOnly, false)) + assertTrue(LocalAddressBookStore.shouldBeReadOnly(collectionNotReadOnly, true)) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalAddressBookTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalAddressBookTest.kt index e47cda020..3de039853 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalAddressBookTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalAddressBookTest.kt @@ -6,13 +6,13 @@ package at.bitfire.davdroid.resource import android.Manifest import android.accounts.Account +import android.accounts.AccountManager import android.content.ContentProviderClient import android.content.ContentUris import android.content.Context import android.provider.ContactsContract import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule -import at.bitfire.davdroid.db.Collection import at.bitfire.vcard4android.Contact import at.bitfire.vcard4android.GroupMethod import at.bitfire.vcard4android.LabeledProperty @@ -20,21 +20,18 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import ezvcard.property.Telephone -import io.mockk.every -import io.mockk.mockk -import java.util.LinkedList -import javax.inject.Inject import org.junit.After import org.junit.AfterClass import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue import org.junit.Before import org.junit.BeforeClass import org.junit.ClassRule import org.junit.Rule import org.junit.Test +import java.util.LinkedList +import javax.inject.Inject @HiltAndroidTest class LocalAddressBookTest { @@ -63,7 +60,8 @@ class LocalAddressBookTest { @After fun tearDown() { // remove address book - addressBook.deleteCollection() + val accountManager = AccountManager.get(context) + accountManager.removeAccountExplicitly(addressBook.addressBookAccount) } @@ -127,21 +125,6 @@ class LocalAddressBookTest { assertEquals("Test Group", group.displayName) } - /** - * Tests the calculation of read only state is correct - */ - @Test - fun test_shouldBeReadOnly() { - val collectionReadOnly = mockk { every { readOnly() } returns true } - assertTrue(LocalAddressBook.shouldBeReadOnly(collectionReadOnly, false)) - assertTrue(LocalAddressBook.shouldBeReadOnly(collectionReadOnly, true)) - - val collectionNotReadOnly = mockk { every { readOnly() } returns false } - assertFalse(LocalAddressBook.shouldBeReadOnly(collectionNotReadOnly, false)) - assertTrue(LocalAddressBook.shouldBeReadOnly(collectionNotReadOnly, true)) - } - - companion object { @JvmField diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalCalendarTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalCalendarTest.kt index e0c4dc3ff..f875d1213 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalCalendarTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalCalendarTest.kt @@ -66,7 +66,7 @@ class LocalCalendarTest { @After fun tearDown() { - calendar.deleteCollection() + calendar.delete() } diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalEventTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalEventTest.kt index 89a2fc6bf..01a3ac645 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalEventTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalEventTest.kt @@ -12,6 +12,7 @@ import android.os.Build import android.provider.CalendarContract import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL import android.provider.CalendarContract.Events +import androidx.datastore.dataStore import androidx.test.platform.app.InstrumentationRegistry import at.bitfire.davdroid.InitCalendarProviderRule import at.bitfire.ical4android.AndroidCalendar @@ -74,7 +75,7 @@ class LocalEventTest { @After fun removeCalendar() { - calendar.deleteCollection() + calendar.delete() } @@ -282,7 +283,7 @@ class LocalEventTest { }) } val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, 0) - val uri = localEvent.add() + localEvent.add() calendar.findById(localEvent.id!!) diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/LocalTestCollection.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/LocalTestCollection.kt index 9b1d60cf5..84b804c1d 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/LocalTestCollection.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/LocalTestCollection.kt @@ -21,8 +21,6 @@ class LocalTestCollection( override val readOnly: Boolean get() = throw NotImplementedError() - override fun deleteCollection(): Boolean = true - override fun findDeleted() = entries.filter { it.deleted } override fun findDirty() = entries.filter { it.dirty } diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/SyncerTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/SyncerTest.kt index 8f7944a7d..f3cf90771 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/SyncerTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/SyncerTest.kt @@ -7,56 +7,42 @@ package at.bitfire.davdroid.sync import android.accounts.Account import android.content.ContentProviderClient import at.bitfire.davdroid.db.Collection -import at.bitfire.davdroid.sync.account.TestAccountAuthenticator -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest +import at.bitfire.davdroid.resource.LocalDataStore import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.impl.annotations.SpyK +import io.mockk.junit4.MockKRule +import io.mockk.just import io.mockk.mockk -import io.mockk.spyk +import io.mockk.runs import io.mockk.verify import okhttp3.HttpUrl.Companion.toHttpUrl -import org.junit.After import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue -import org.junit.Before import org.junit.Rule import org.junit.Test -import javax.inject.Inject +import java.util.logging.Logger -@HiltAndroidTest class SyncerTest { @get:Rule - val hiltRule = HiltAndroidRule(this) + val mockkRule = MockKRule(this) - @Inject - lateinit var testSyncer: TestSyncer.Factory + @RelaxedMockK + lateinit var logger: Logger - lateinit var account: Account + val dataStore: LocalTestStore = mockk(relaxed = true) + val provider: ContentProviderClient = mockk(relaxed = true) - private lateinit var syncer: TestSyncer - - @Before - fun setUp() { - hiltRule.inject() - - account = TestAccountAuthenticator.create() - syncer = spyk(testSyncer.create(account, emptyArray(), SyncResult())) - } - - @After - fun tearDown() { - TestAccountAuthenticator.remove(account) - } + @SpyK + @InjectMockKs + var syncer = TestSyncer(mockk(relaxed = true), emptyArray(), SyncResult(), dataStore) @Test fun testSync_prepare_fails() { - val provider = mockk() every { syncer.prepare(provider) } returns false every { syncer.getSyncEnabledCollections() } returns emptyMap() @@ -68,7 +54,6 @@ class SyncerTest { @Test fun testSync_prepare_succeeds() { - val provider = mockk() every { syncer.prepare(provider) } returns true every { syncer.getSyncEnabledCollections() } returns emptyMap() @@ -83,13 +68,12 @@ class SyncerTest { fun testUpdateCollections_deletesCollection() { val localCollection = mockk() every { localCollection.collectionUrl } returns "http://delete.the/collection" - every { localCollection.deleteCollection() } returns true every { localCollection.title } returns "Collection to be deleted locally" // Should delete the localCollection if dbCollection (remote) does not exist val localCollections = mutableListOf(localCollection) val result = syncer.updateCollections(mockk(), localCollections, emptyMap()) - verify(exactly = 1) { localCollection.deleteCollection() } + verify(exactly = 1) { dataStore.delete(localCollection) } // Updated local collection list should be empty assertTrue(result.isEmpty()) @@ -105,8 +89,8 @@ class SyncerTest { every { localCollection.title } returns "The Local Collection" // Should update the localCollection if it exists - val result = syncer.updateCollections(mockk(), listOf(localCollection), dbCollections) - verify(exactly = 1) { syncer.update(localCollection, dbCollection) } + val result = syncer.updateCollections(provider, listOf(localCollection), dbCollections) + verify(exactly = 1) { dataStore.update(provider, localCollection, dbCollection) } // Updated local collection list should be same as input assertArrayEquals(arrayOf(localCollection), result.toTypedArray()) @@ -114,12 +98,18 @@ class SyncerTest { @Test fun testUpdateCollections_findsNewCollection() { - val dbCollection = mockk() - every { dbCollection.url } returns "http://newly.found/collection".toHttpUrl() - val dbCollections = mapOf(dbCollection.url to dbCollection) + val dbCollection = mockk { + every { url } returns "http://newly.found/collection".toHttpUrl() + } + val localCollections = listOf(mockk { + every { collectionUrl } returns "http://newly.found/collection" + }) + val dbCollections = listOf(dbCollection) + val dbCollectionsMap = mapOf(dbCollection.url to dbCollection) + every { syncer.createLocalCollections(provider, dbCollections) } returns localCollections // Should return the new collection, because it was not updated - val result = syncer.updateCollections(mockk(), emptyList(), dbCollections) + val result = syncer.updateCollections(provider, emptyList(), dbCollectionsMap) // Updated local collection list contain new entry assertEquals(1, result.size) @@ -129,21 +119,18 @@ class SyncerTest { @Test fun testCreateLocalCollections() { - val provider = mockk() val localCollection = mockk() val dbCollection = mockk() - every { syncer.create(provider, dbCollection) } returns localCollection - every { dbCollection.url } returns "http://newly.found/collection".toHttpUrl() + every { dataStore.create(provider, dbCollection) } returns localCollection // Should return list of newly created local collections val result = syncer.createLocalCollections(provider, listOf(dbCollection)) assertEquals(listOf(localCollection), result) } - + @Test fun testSyncCollectionContents() { - val provider = mockk() val dbCollection1 = mockk() val dbCollection2 = mockk() val dbCollections = mapOf( @@ -155,6 +142,7 @@ class SyncerTest { val localCollections = listOf(localCollection1, localCollection2) every { localCollection1.collectionUrl } returns "http://newly.found/collection1" every { localCollection2.collectionUrl } returns "http://newly.found/collection2" + every { syncer.syncCollection(provider, any(), any()) } just runs // Should call the collection content sync on both collections syncer.syncCollectionContents(provider, localCollections, dbCollections) @@ -165,41 +153,65 @@ class SyncerTest { // Test helpers - class TestSyncer @AssistedInject constructor( - @Assisted account: Account, - @Assisted extras: Array, - @Assisted syncResult: SyncResult - ) : Syncer(account, extras, syncResult) { + class TestSyncer ( + account: Account, + extras: Array, + syncResult: SyncResult, + theDataStore: LocalTestStore + ) : Syncer(account, extras, syncResult) { - @AssistedFactory - interface Factory { - fun create(account: Account, extras: Array, syncResult: SyncResult): TestSyncer - } + override val dataStore: LocalTestStore = + theDataStore override val authority: String - get() = "" + get() = throw NotImplementedError() + override val serviceType: String - get() = "" + get() = throw NotImplementedError() override fun prepare(provider: ContentProviderClient): Boolean = - true - - override fun getLocalCollections(provider: ContentProviderClient): List = - emptyList() + throw NotImplementedError() override fun getDbSyncCollections(serviceId: Long): List = - emptyList() - - override fun create(provider: ContentProviderClient, remoteCollection: Collection): LocalTestCollection = - LocalTestCollection(remoteCollection.url.toString()) + throw NotImplementedError() override fun syncCollection( provider: ContentProviderClient, localCollection: LocalTestCollection, remoteCollection: Collection - ) {} + ) { + throw NotImplementedError() + } - override fun update(localCollection: LocalTestCollection, remoteCollection: Collection) {} + } + + class LocalTestStore : LocalDataStore { + + override fun create( + provider: ContentProviderClient, + fromCollection: Collection + ): LocalTestCollection? { + throw NotImplementedError() + } + + override fun getAll( + account: Account, + provider: ContentProviderClient + ): List { + throw NotImplementedError() + } + + override fun update( + provider: ContentProviderClient, + localCollection: LocalTestCollection, + fromCollection: Collection + ) { + throw NotImplementedError() + } + + override fun delete(localCollection: LocalTestCollection) { + throw NotImplementedError() + } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt index 09e79d18d..ca38a8c59 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt @@ -15,7 +15,7 @@ import at.bitfire.davdroid.R import at.bitfire.davdroid.db.Credentials import at.bitfire.davdroid.db.HomeSet import at.bitfire.davdroid.db.Service -import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.davdroid.resource.LocalAddressBookStore import at.bitfire.davdroid.resource.LocalTaskList import at.bitfire.davdroid.servicedetection.DavResourceFinder import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker @@ -49,6 +49,7 @@ class AccountRepository @Inject constructor( @ApplicationContext val context: Context, private val collectionRepository: DavCollectionRepository, private val homeSetRepository: DavHomeSetRepository, + private val localAddressBookStore: Lazy, private val logger: Logger, private val settingsManager: SettingsManager, private val serviceRepository: DavServiceRepository, @@ -140,7 +141,7 @@ class AccountRepository @Inject constructor( // delete address books (= address book accounts) serviceRepository.getByAccountAndType(accountName, Service.TYPE_CARDDAV)?.let { service -> collectionRepository.getByService(service.id).forEach { collection -> - LocalAddressBook.deleteByCollection(context, collection.id) + localAddressBookStore.get().deleteByCollectionId(collection.id) } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalAddressBook.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalAddressBook.kt index e1ca08239..b22d8ede5 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalAddressBook.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalAddressBook.kt @@ -17,16 +17,13 @@ import android.provider.ContactsContract.CommonDataKinds.GroupMembership import android.provider.ContactsContract.Groups import android.provider.ContactsContract.RawContacts import androidx.annotation.OpenForTesting -import androidx.annotation.VisibleForTesting import at.bitfire.davdroid.R -import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.db.SyncState import at.bitfire.davdroid.repository.DavCollectionRepository import at.bitfire.davdroid.repository.DavServiceRepository import at.bitfire.davdroid.resource.workaround.ContactDirtyVerifier import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.sync.account.SystemAccountUtils -import at.bitfire.davdroid.util.DavUtils.lastSegment import at.bitfire.davdroid.util.setAndVerifyUserData import at.bitfire.vcard4android.AndroidAddressBook import at.bitfire.vcard4android.AndroidContact @@ -36,11 +33,7 @@ import at.bitfire.vcard4android.GroupMethod import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import dagger.hilt.EntryPoint -import dagger.hilt.InstallIn -import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent import java.util.LinkedList import java.util.Optional import java.util.logging.Level @@ -74,7 +67,6 @@ open class LocalAddressBook @AssistedInject constructor( fun create(addressBookAccount: Account, provider: ContentProviderClient): LocalAddressBook } - override val tag: String get() = "contacts-${addressBookAccount.name}" @@ -111,9 +103,42 @@ open class LocalAddressBook @AssistedInject constructor( ?: throw IllegalStateException("Address book has no URL") set(url) = AccountManager.get(context).setAndVerifyUserData(addressBookAccount, USER_DATA_URL, url) + /** + * Read-only flag for the address book itself. + * + * Setting this flag: + * + * - stores the new value in [USER_DATA_READ_ONLY] and + * - sets the read-only flag for all contacts and groups in the address book in the content provider, which will + * prevent non-sync-adapter apps from modifying them. However new entries can still be created, so the address book + * is not really read-only. + * + * Reading this flag returns the stored value from [USER_DATA_READ_ONLY]. + */ override var readOnly: Boolean get() = AccountManager.get(context).getUserData(addressBookAccount, USER_DATA_READ_ONLY) != null - set(readOnly) = AccountManager.get(context).setAndVerifyUserData(addressBookAccount, USER_DATA_READ_ONLY, if (readOnly) "1" else null) + set(readOnly) { + // set read-only flag for address book itself + AccountManager.get(context).setAndVerifyUserData(addressBookAccount, USER_DATA_READ_ONLY, if (readOnly) "1" else null) + + // update raw contacts + val rawContactValues = ContentValues(1).apply { + put(RawContacts.RAW_CONTACT_IS_READ_ONLY, if (readOnly) 1 else 0) + } + provider!!.update(rawContactsSyncUri(), rawContactValues, null, null) + + // update data rows + val dataValues = ContentValues(1).apply { + put(ContactsContract.Data.IS_READ_ONLY, if (readOnly) 1 else 0) + } + provider!!.update(syncAdapterURI(ContactsContract.Data.CONTENT_URI), dataValues, null, null) + + // update group rows + val groupValues = ContentValues(1).apply { + put(Groups.GROUP_IS_READ_ONLY, if (readOnly) 1 else 0) + } + provider!!.update(groupsSyncUri(), groupValues, null, null) + } override var lastSyncState: SyncState? get() = syncState?.let { SyncState.fromString(String(it)) } @@ -149,59 +174,6 @@ open class LocalAddressBook @AssistedInject constructor( return number } - /** - * Updates the address book settings. - * - * @param info collection where to take the settings from - * @param forceReadOnly `true`: set the address book to "force read-only"; - * `false`: determine read-only flag from [info]; - */ - fun update(info: Collection, forceReadOnly: Boolean) { - logger.log(Level.INFO, "Updating local address book $addressBookAccount with collection $info") - val accountManager = AccountManager.get(context) - - // Update the account name - val newAccountName = accountName(context, info) - if (addressBookAccount.name != newAccountName) - // rename, move contacts/groups and update [AndroidAddressBook.]account - renameAccount(newAccountName) - - // Update the account user data - accountManager.setAndVerifyUserData(addressBookAccount, USER_DATA_COLLECTION_ID, info.id.toString()) - accountManager.setAndVerifyUserData(addressBookAccount, USER_DATA_URL, info.url.toString()) - - // Set contacts provider settings - settings = contactsProviderSettings - - // Update force read only - val nowReadOnly = shouldBeReadOnly(info, forceReadOnly) - if (nowReadOnly != readOnly) { - logger.info("Address book now read-only = $nowReadOnly, updating contacts") - - // update address book itself - readOnly = nowReadOnly - - // update raw contacts - val rawContactValues = ContentValues(1) - rawContactValues.put(RawContacts.RAW_CONTACT_IS_READ_ONLY, if (nowReadOnly) 1 else 0) - provider!!.update(rawContactsSyncUri(), rawContactValues, null, null) - - // update data rows - val dataValues = ContentValues(1) - dataValues.put(ContactsContract.Data.IS_READ_ONLY, if (nowReadOnly) 1 else 0) - provider!!.update(syncAdapterURI(ContactsContract.Data.CONTENT_URI), dataValues, null, null) - - // update group rows - val groupValues = ContentValues(1) - groupValues.put(Groups.GROUP_IS_READ_ONLY, if (nowReadOnly) 1 else 0) - provider!!.update(groupsSyncUri(), groupValues, null, null) - } - - - // make sure it will still be synchronized when contacts are updated - updateSyncFrameworkSettings() - } - /** * Renames an address book account and moves the contacts and groups (without making them dirty). * Does not keep user data of the old account, so these have to be set again. @@ -215,7 +187,6 @@ open class LocalAddressBook @AssistedInject constructor( * * @return whether the account was renamed successfully */ - @VisibleForTesting internal fun renameAccount(newName: String): Boolean { val oldAccount = addressBookAccount logger.info("Renaming address book from \"${oldAccount.name}\" to \"$newName\"") @@ -249,11 +220,6 @@ open class LocalAddressBook @AssistedInject constructor( return true } - override fun deleteCollection(): Boolean { - val accountManager = AccountManager.get(context) - return accountManager.removeAccountExplicitly(addressBookAccount) - } - /** * Updates the sync framework settings for this address book: @@ -381,143 +347,27 @@ open class LocalAddressBook @AssistedInject constructor( companion object { - @EntryPoint - @InstallIn(SingletonComponent::class) - interface LocalAddressBookCompanionEntryPoint { - fun localAddressBookFactory(): Factory - fun serviceRepository(): DavServiceRepository - fun logger(): Logger - } - - const val USER_DATA_URL = "url" - const val USER_DATA_COLLECTION_ID = "collection_id" - const val USER_DATA_READ_ONLY = "read_only" - - /** - * Contacts Provider Settings (equal for every address book) - */ - val contactsProviderSettings = ContentValues(2).apply { - // SHOULD_SYNC is just a hint that an account's contacts (the contacts of this local - // address book) are syncable. - put(ContactsContract.Settings.SHOULD_SYNC, 1) - // UNGROUPED_VISIBLE is required for making contacts work over Bluetooth (especially - // with some car systems). - put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1) - } - - // create/query/delete - - /** - * Creates a new local address book. - * - * @param context app context to resolve string resources - * @param provider contacts provider client - * @param info collection where to take the name and settings from - * @param forceReadOnly `true`: set the address book to "force read-only"; `false`: determine read-only flag from [info] - */ - fun create(context: Context, provider: ContentProviderClient, info: Collection, forceReadOnly: Boolean): LocalAddressBook { - val entryPoint = EntryPointAccessors.fromApplication(context) - val logger = entryPoint.logger() - - val account = Account(accountName(context, info), context.getString(R.string.account_type_address_book)) - val userData = initialUserData(info.url.toString(), info.id.toString()) - logger.log(Level.INFO, "Creating local address book $account", userData) - if (!SystemAccountUtils.createAccount(context, account, userData)) - throw IllegalStateException("Couldn't create address book account") - - val factory = entryPoint.localAddressBookFactory() - val addressBook = factory.create(account, provider) - - addressBook.updateSyncFrameworkSettings() - addressBook.settings = contactsProviderSettings - addressBook.readOnly = shouldBeReadOnly(info, forceReadOnly) - - return addressBook - } - /** - * Determines whether the address book should be set to read-only. + * URL of the corresponding CardDAV address book. * - * @param forceReadOnly Whether (usually managed, app-wide) setting should overwrite local read-only information - * @param info Collection data to determine read-only status from (either user-set read-only flag or missing write privilege) + * User data of the address book account (String). */ - @VisibleForTesting - internal fun shouldBeReadOnly(info: Collection, forceReadOnly: Boolean): Boolean = - info.readOnly() || forceReadOnly - - /** - * Finds a [LocalAddressBook] based on its corresponding collection. - * - * @param id collection ID to look for - * - * @return The [LocalAddressBook] for the given collection or *null* if not found - */ - fun findByCollection(context: Context, provider: ContentProviderClient, id: Long): LocalAddressBook? { - val entryPoint = EntryPointAccessors.fromApplication(context) - val factory = entryPoint.localAddressBookFactory() - - val accountManager = AccountManager.get(context) - return accountManager - .getAccountsByType(context.getString(R.string.account_type_address_book)) - .filter { account -> - accountManager.getUserData(account, USER_DATA_COLLECTION_ID)?.toLongOrNull() == id - } - .map { account -> factory.create(account, provider) } - .firstOrNull() - } + @Deprecated("Use the URL of the DB collection instead") + const val USER_DATA_URL = "url" /** - * Deletes a [LocalAddressBook] based on its corresponding database collection. + * ID of the corresponding database [at.bitfire.davdroid.db.Collection]. * - * @param id collection ID to look for + * User data of the address book account (Long). */ - fun deleteByCollection(context: Context, id: Long) { - val accountManager = AccountManager.get(context) - val addressBookAccount = accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).firstOrNull { account -> - accountManager.getUserData(account, USER_DATA_COLLECTION_ID)?.toLongOrNull() == id - } - if (addressBookAccount != null) - accountManager.removeAccountExplicitly(addressBookAccount) - } - - - // helpers + const val USER_DATA_COLLECTION_ID = "collection_id" /** - * Creates a name for the address book account from its corresponding db collection info. - * - * The address book account name contains - * - the collection display name or last URL path segment - * - the actual account name - * - the collection ID, to make it unique. + * Indicates whether the address book is currently set to read-only (i.e. its contacts and groups have the read-only flag). * - * @param info The corresponding collection + * User data of the address book account (Boolean). */ - fun accountName(context: Context, info: Collection): String { - // Name the address book after given collection display name, otherwise use last URL path segment - val sb = StringBuilder(info.displayName.let { - if (it.isNullOrEmpty()) - info.url.lastSegment - else - it - }) - // Add the actual account name to the address book account name - val entryPoint = EntryPointAccessors.fromApplication(context) - val serviceRepository = entryPoint.serviceRepository() - serviceRepository.get(info.serviceId)?.let { service -> - sb.append(" (${service.accountName})") - } - // Add the collection ID for uniqueness - sb.append(" #${info.id}") - return sb.toString() - } - - private fun initialUserData(url: String, collectionId: String): Bundle { - val bundle = Bundle(3) - bundle.putString(USER_DATA_COLLECTION_ID, collectionId) - bundle.putString(USER_DATA_URL, url) - return bundle - } + const val USER_DATA_READ_ONLY = "read_only" } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalAddressBookStore.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalAddressBookStore.kt new file mode 100644 index 000000000..232d88f5a --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalAddressBookStore.kt @@ -0,0 +1,216 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.ContentProviderClient +import android.content.ContentValues +import android.content.Context +import android.os.Bundle +import android.provider.ContactsContract +import androidx.annotation.VisibleForTesting +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.repository.DavCollectionRepository +import at.bitfire.davdroid.repository.DavServiceRepository +import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_COLLECTION_ID +import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_URL +import at.bitfire.davdroid.settings.Settings +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.sync.account.SystemAccountUtils +import at.bitfire.davdroid.util.DavUtils.lastSegment +import at.bitfire.davdroid.util.setAndVerifyUserData +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject +import kotlin.collections.orEmpty + +class LocalAddressBookStore @Inject constructor( + val collectionRepository: DavCollectionRepository, + @ApplicationContext val context: Context, + val localAddressBookFactory: LocalAddressBook.Factory, + val logger: Logger, + val serviceRepository: DavServiceRepository, + val settings: SettingsManager + ): LocalDataStore { + + /** whether a (usually managed) setting wants all address-books to be read-only **/ + val forceAllReadOnly: Boolean + get() = settings.getBoolean(Settings.FORCE_READ_ONLY_ADDRESSBOOKS) + + + /** + * Assembles a name for the address book (account) from its corresponding database [Collection]. + * + * The address book account name contains + * + * - the collection display name or last URL path segment + * - the actual account name + * - the collection ID, to make it unique. + * + * @param info Collection to take info from + */ + fun accountName(info: Collection): String { + // Name the address book after given collection display name, otherwise use last URL path segment + val sb = StringBuilder(info.displayName.let { + if (it.isNullOrEmpty()) + info.url.lastSegment + else + it + }) + // Add the actual account name to the address book account name + serviceRepository.get(info.serviceId)?.let { service -> + sb.append(" (${service.accountName})") + } + // Add the collection ID for uniqueness + sb.append(" #${info.id}") + return sb.toString() + } + + + override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalAddressBook? { + val name = accountName(fromCollection) + val account = createAccount( + name = name, + id = fromCollection.id, + url = fromCollection.url.toString() + ) ?: return null + + val addressBook = localAddressBookFactory.create(account, provider) + + // update settings + addressBook.updateSyncFrameworkSettings() + addressBook.settings = contactsProviderSettings + addressBook.readOnly = shouldBeReadOnly(fromCollection, forceAllReadOnly) + + return addressBook + } + + fun createAccount(name: String, id: Long, url: String): Account? { + // create account with collection ID and URL + val account = Account(name, context.getString(R.string.account_type_address_book)) + val userData = Bundle(2).apply { + putString(USER_DATA_COLLECTION_ID, id.toString()) + putString(USER_DATA_URL, url) + } + if (!SystemAccountUtils.createAccount(context, account, userData)) { + logger.warning("Couldn't create address book account: $account") + return null + } + + return account + } + + + override fun getAll(account: Account, provider: ContentProviderClient): List = + serviceRepository.getByAccountAndType(account.name, Service.TYPE_CARDDAV)?.let { service -> + // get all collections for the CardDAV service + collectionRepository.getByService(service.id).mapNotNull { collection -> + // and map to a LocalAddressBook, if applicable + findByCollection(provider, collection.id) + } + }.orEmpty() + + /** + * Finds a [LocalAddressBook] based on its corresponding collection. + * + * @param id collection ID to look for + * + * @return The [LocalAddressBook] for the given collection or *null* if not found + */ + private fun findByCollection(provider: ContentProviderClient, id: Long): LocalAddressBook? { + val accountManager = AccountManager.get(context) + return accountManager + .getAccountsByType(context.getString(R.string.account_type_address_book)) + .filter { account -> + accountManager.getUserData(account, USER_DATA_COLLECTION_ID)?.toLongOrNull() == id + } + .map { account -> localAddressBookFactory.create(account, provider) } + .firstOrNull() + } + + + override fun update(provider: ContentProviderClient, localCollection: LocalAddressBook, fromCollection: Collection) { + var currentAccount = localCollection.addressBookAccount + logger.log(Level.INFO, "Updating local address book $currentAccount from collection $fromCollection") + + // Update the account name + val newAccountName = accountName(fromCollection) + if (currentAccount.name != newAccountName) { + // rename, move contacts/groups and update [AndroidAddressBook.]account + localCollection.renameAccount(newAccountName) + currentAccount.name = newAccountName + } + + // Update the account user data + val accountManager = AccountManager.get(context) + accountManager.setAndVerifyUserData(currentAccount, USER_DATA_COLLECTION_ID, fromCollection.id.toString()) + accountManager.setAndVerifyUserData(currentAccount, USER_DATA_URL, fromCollection.url.toString()) + + // Set contacts provider settings + localCollection.settings = contactsProviderSettings + + // Update force read only + val nowReadOnly = shouldBeReadOnly(fromCollection, forceAllReadOnly) + if (nowReadOnly != localCollection.readOnly) { + logger.info("Address book has changed to read-only = $nowReadOnly") + localCollection.readOnly = nowReadOnly + } + + // make sure it will still be synchronized when contacts are updated + localCollection.updateSyncFrameworkSettings() + } + + + override fun delete(localCollection: LocalAddressBook) { + val accountManager = AccountManager.get(context) + accountManager.removeAccountExplicitly(localCollection.addressBookAccount) + } + + /** + * Deletes a [LocalAddressBook] based on its corresponding database collection. + * + * @param id [Collection.id] to look for + */ + fun deleteByCollectionId(id: Long) { + val accountManager = AccountManager.get(context) + val addressBookAccount = accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).firstOrNull { account -> + accountManager.getUserData(account, USER_DATA_COLLECTION_ID)?.toLongOrNull() == id + } + if (addressBookAccount != null) + accountManager.removeAccountExplicitly(addressBookAccount) + } + + + companion object { + + /** + * Contacts Provider Settings (equal for every address book) + */ + val contactsProviderSettings + get() = ContentValues(2).apply { + // SHOULD_SYNC is just a hint that an account's contacts (the contacts of this local address book) are syncable. + put(ContactsContract.Settings.SHOULD_SYNC, 1) + + // UNGROUPED_VISIBLE is required for making contacts work over Bluetooth (especially with some car systems). + put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1) + } + + /** + * Determines whether the address book should be set to read-only. + * + * @param forceAllReadOnly Whether (usually managed, app-wide) setting should overwrite local read-only information + * @param info Collection data to determine read-only status from (either user-set read-only flag or missing write privilege) + */ + @VisibleForTesting + internal fun shouldBeReadOnly(info: Collection, forceAllReadOnly: Boolean): Boolean = + info.readOnly() || forceAllReadOnly + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendar.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendar.kt index 535c683c9..b197e3dd0 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendar.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendar.kt @@ -8,17 +8,12 @@ import android.accounts.Account import android.content.ContentProviderClient import android.content.ContentUris import android.content.ContentValues -import android.net.Uri import android.provider.CalendarContract.Calendars import android.provider.CalendarContract.Events -import at.bitfire.davdroid.Constants -import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.db.SyncState -import at.bitfire.davdroid.util.DavUtils.lastSegment import at.bitfire.ical4android.AndroidCalendar import at.bitfire.ical4android.AndroidCalendarFactory import at.bitfire.ical4android.BatchOperation -import at.bitfire.ical4android.util.DateUtils import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter import java.util.LinkedList import java.util.logging.Level @@ -42,60 +37,6 @@ class LocalCalendar private constructor( private val logger: Logger get() = Logger.getGlobal() - fun create(account: Account, provider: ContentProviderClient, info: Collection): Uri { - // If the collection doesn't have a color, use a default color. - if (info.color != null) - info.color = Constants.DAVDROID_GREEN_RGBA - - val values = valuesFromCollectionInfo(info, withColor = true) - - // ACCOUNT_NAME and ACCOUNT_TYPE are required (see docs)! If it's missing, other apps will crash. - values.put(Calendars.ACCOUNT_NAME, account.name) - values.put(Calendars.ACCOUNT_TYPE, account.type) - - // Email address for scheduling. Used by the calendar provider to determine whether the - // user is ORGANIZER/ATTENDEE for a certain event. - values.put(Calendars.OWNER_ACCOUNT, account.name) - - // flag as visible & synchronizable at creation, might be changed by user at any time - values.put(Calendars.VISIBLE, 1) - values.put(Calendars.SYNC_EVENTS, 1) - return create(account, provider, values) - } - - private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues { - val values = ContentValues() - values.put(Calendars.NAME, info.url.toString()) - values.put(Calendars.CALENDAR_DISPLAY_NAME, - if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName) - - if (withColor && info.color != null) - values.put(Calendars.CALENDAR_COLOR, info.color) - - if (info.privWriteContent && !info.forceReadOnly) { - values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER) - values.put(Calendars.CAN_MODIFY_TIME_ZONE, 1) - values.put(Calendars.CAN_ORGANIZER_RESPOND, 1) - } else - values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ) - - info.timezone?.let { tzData -> - try { - val timeZone = DateUtils.parseVTimeZone(tzData) - timeZone.timeZoneId?.let { tzId -> - values.put(Calendars.CALENDAR_TIME_ZONE, DateUtils.findAndroidTimezoneID(tzId.value)) - } - } catch(e: IllegalArgumentException) { - logger.log(Level.WARNING, "Couldn't parse calendar default time zone", e) - } - } - - // add base values for Calendars - values.putAll(calendarBaseValues) - - return values - } - } override val collectionUrl: String? @@ -111,8 +52,6 @@ class LocalCalendar private constructor( override val readOnly get() = accessLevel <= Calendars.CAL_ACCESS_READ - override fun deleteCollection(): Boolean = delete() - override var lastSyncState: SyncState? get() = provider.query(calendarSyncURI(), arrayOf(COLUMN_SYNC_STATE), null, null, null)?.use { cursor -> if (cursor.moveToNext()) @@ -132,9 +71,6 @@ class LocalCalendar private constructor( accessLevel = info.getAsInteger(Calendars.CALENDAR_ACCESS_LEVEL) ?: Calendars.CAL_ACCESS_OWNER } - fun update(info: Collection, updateColor: Boolean) = - update(valuesFromCollectionInfo(info, updateColor)) - override fun findDeleted() = queryEvents("${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendarStore.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendarStore.kt new file mode 100644 index 000000000..9bb8d874a --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendarStore.kt @@ -0,0 +1,116 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.provider.CalendarContract.Calendars +import at.bitfire.davdroid.Constants +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.util.DavUtils.lastSegment +import at.bitfire.ical4android.AndroidCalendar +import at.bitfire.ical4android.AndroidCalendar.Companion.calendarBaseValues +import at.bitfire.ical4android.util.DateUtils +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject + +class LocalCalendarStore @Inject constructor( + @ApplicationContext val context: Context, + val accountSettingsFactory: AccountSettings.Factory, + db: AppDatabase, + val logger: Logger +): LocalDataStore { + + private val serviceDao = db.serviceDao() + + + override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalCalendar? { + val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection") + val account = Account(service.accountName, context.getString(R.string.account_type)) + + // If the collection doesn't have a color, use a default color. + if (fromCollection.color != null) + fromCollection.color = Constants.DAVDROID_GREEN_RGBA + + val values = valuesFromCollectionInfo(fromCollection, withColor = true) + + // ACCOUNT_NAME and ACCOUNT_TYPE are required (see docs)! If it's missing, other apps will crash. + values.put(Calendars.ACCOUNT_NAME, account.name) + values.put(Calendars.ACCOUNT_TYPE, account.type) + + // Email address for scheduling. Used by the calendar provider to determine whether the + // user is ORGANIZER/ATTENDEE for a certain event. + values.put(Calendars.OWNER_ACCOUNT, account.name) + + // flag as visible & syncable at creation, might be changed by user at any time + values.put(Calendars.VISIBLE, 1) + values.put(Calendars.SYNC_EVENTS, 1) + + logger.log(Level.INFO, "Adding local calendar", values) + val uri = AndroidCalendar.create(account, provider, values) + return AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri)) + } + + + override fun getAll(account: Account, provider: ContentProviderClient) = + AndroidCalendar.find(account, provider, LocalCalendar.Factory, "${Calendars.SYNC_EVENTS}!=0", null) + + + override fun update(provider: ContentProviderClient, localCollection: LocalCalendar, fromCollection: Collection) { + val accountSettings = accountSettingsFactory.create(localCollection.account) + val values = valuesFromCollectionInfo(fromCollection, withColor = accountSettings.getManageCalendarColors()) + + logger.log(Level.FINE, "Updating local calendar ${fromCollection.url}", values) + localCollection.update(values) + } + + private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues { + val values = ContentValues() + values.put(Calendars.NAME, info.url.toString()) + values.put(Calendars.CALENDAR_DISPLAY_NAME, + if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName) + + if (withColor && info.color != null) + values.put(Calendars.CALENDAR_COLOR, info.color) + + if (info.privWriteContent && !info.forceReadOnly) { + values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER) + values.put(Calendars.CAN_MODIFY_TIME_ZONE, 1) + values.put(Calendars.CAN_ORGANIZER_RESPOND, 1) + } else + values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ) + + info.timezone?.let { tzData -> + try { + val timeZone = DateUtils.parseVTimeZone(tzData) + timeZone.timeZoneId?.let { tzId -> + values.put(Calendars.CALENDAR_TIME_ZONE, DateUtils.findAndroidTimezoneID(tzId.value)) + } + } catch(e: IllegalArgumentException) { + logger.log(Level.WARNING, "Couldn't parse calendar default time zone", e) + } + } + + // add base values for Calendars + values.putAll(calendarBaseValues) + + return values + } + + + override fun delete(localCollection: LocalCalendar) { + logger.log(Level.INFO, "Deleting local calendar", localCollection) + localCollection.delete() + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCollection.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCollection.kt index a021fe252..2028866e7 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCollection.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCollection.kt @@ -4,7 +4,6 @@ package at.bitfire.davdroid.resource -import android.provider.CalendarContract.Events import at.bitfire.davdroid.db.SyncState interface LocalCollection> { @@ -27,13 +26,6 @@ interface LocalCollection> { */ val readOnly: Boolean - /** - * Deletes the local collection. - * - * @return true if the collection was deleted, false otherwise - */ - fun deleteCollection(): Boolean - /** * Finds local resources of this collection which have been marked as *deleted* by the user * or an app acting on their behalf. diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalDataStore.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalDataStore.kt new file mode 100644 index 000000000..56329de3c --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalDataStore.kt @@ -0,0 +1,53 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import android.accounts.Account +import android.content.ContentProviderClient +import at.bitfire.davdroid.db.Collection + +/** + * Represents a local data store for a specific collection type. + * Manages creation, update, and deletion of collections of the given type. + */ +interface LocalDataStore> { + + /** + * Creates a new local collection from the given (remote) collection info. + * + * @param provider the content provider client + * @param fromCollection collection info + * + * @return the new local collection, or `null` if creation failed + */ + fun create(provider: ContentProviderClient, fromCollection: Collection): T? + + /** + * Returns all local collections of the data store. + * + * @param account the account that the data store is associated with + * @param provider the content provider client + * + * @return a list of all local collections + */ + fun getAll(account: Account, provider: ContentProviderClient): List + + /** + * Updates the local collection with the data from the given (remote) collection info. + * + * @param provider the content provider client + * @param localCollection the local collection to update + * @param fromCollection collection info + */ + fun update(provider: ContentProviderClient, localCollection: T, fromCollection: Collection) + + /** + * Deletes the local collection. + * + * @param localCollection the local collection to delete + */ + fun delete(localCollection: T) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxCollection.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxCollection.kt index 6e0eb082c..478ad95b7 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxCollection.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxCollection.kt @@ -6,64 +6,20 @@ package at.bitfire.davdroid.resource import android.accounts.Account import android.content.ContentProviderClient -import android.content.ContentValues -import android.net.Uri -import at.bitfire.davdroid.Constants import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.db.Principal import at.bitfire.davdroid.db.SyncState -import at.bitfire.davdroid.util.DavUtils.lastSegment import at.bitfire.ical4android.JtxCollection import at.bitfire.ical4android.JtxCollectionFactory import at.bitfire.ical4android.JtxICalObject -import at.techbee.jtx.JtxContract -import java.util.logging.Logger class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Long): JtxCollection(account, client, LocalJtxICalObject.Factory, id), LocalCollection{ - companion object { - - fun create(account: Account, client: ContentProviderClient, info: Collection, owner: Principal?): Uri { - // If the collection doesn't have a color, use a default color. - if (info.color != null) - info.color = Constants.DAVDROID_GREEN_RGBA - - val values = valuesFromCollection(info, account, owner, withColor = true) - - return create(account, client, values) - } - - fun valuesFromCollection(info: Collection, account: Account, owner: Principal?, withColor: Boolean) = - ContentValues().apply { - put(JtxContract.JtxCollection.URL, info.url.toString()) - put( - JtxContract.JtxCollection.DISPLAYNAME, - info.displayName ?: info.url.lastSegment - ) - put(JtxContract.JtxCollection.DESCRIPTION, info.description) - if (owner != null) - put(JtxContract.JtxCollection.OWNER, owner.url.toString()) - else - Logger.getGlobal().warning("No collection owner given. Will create jtx collection without owner") - put(JtxContract.JtxCollection.OWNER_DISPLAYNAME, owner?.displayName) - if (withColor && info.color != null) - put(JtxContract.JtxCollection.COLOR, info.color) - put(JtxContract.JtxCollection.SUPPORTSVEVENT, info.supportsVEVENT) - put(JtxContract.JtxCollection.SUPPORTSVJOURNAL, info.supportsVJOURNAL) - put(JtxContract.JtxCollection.SUPPORTSVTODO, info.supportsVTODO) - put(JtxContract.JtxCollection.ACCOUNT_NAME, account.name) - put(JtxContract.JtxCollection.ACCOUNT_TYPE, account.type) - put(JtxContract.JtxCollection.READONLY, info.forceReadOnly || !info.privWriteContent) - } - } - override val readOnly: Boolean get() = throw NotImplementedError() - override fun deleteCollection(): Boolean = delete() - override val tag: String get() = "jtx-${account.name}-$id" override val collectionUrl: String? @@ -74,10 +30,6 @@ class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Lo get() = SyncState.fromString(syncstate) set(value) { syncstate = value.toString() } - fun updateCollection(info: Collection, owner: Principal?, updateColor: Boolean) { - val values = valuesFromCollection(info, account, owner, updateColor) - update(values) - } override fun findDeleted(): List { val values = queryDeletedICalObjects() diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxCollectionStore.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxCollectionStore.kt new file mode 100644 index 000000000..c5224beec --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxCollectionStore.kt @@ -0,0 +1,89 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import at.bitfire.davdroid.Constants +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.repository.PrincipalRepository +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.util.DavUtils.lastSegment +import at.bitfire.ical4android.JtxCollection +import at.techbee.jtx.JtxContract +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.logging.Logger +import javax.inject.Inject + +class LocalJtxCollectionStore @Inject constructor( + @ApplicationContext val context: Context, + val accountSettingsFactory: AccountSettings.Factory, + db: AppDatabase, + val principalRepository: PrincipalRepository +): LocalDataStore { + + private val serviceDao = db.serviceDao() + + override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalJtxCollection? { + // If the collection doesn't have a color, use a default color. + if (fromCollection.color != null) + fromCollection.color = Constants.DAVDROID_GREEN_RGBA + + val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection") + val account = Account(service.accountName, context.getString(R.string.account_type)) + val values = valuesFromCollection(fromCollection, account = account, withColor = true) + + val uri = JtxCollection.create(account, provider, values) + return LocalJtxCollection(account, provider, ContentUris.parseId(uri)) + } + + private fun valuesFromCollection(info: Collection, account: Account, withColor: Boolean): ContentValues { + val owner = info.ownerId?.let { principalRepository.get(it) } + + return ContentValues().apply { + put(JtxContract.JtxCollection.URL, info.url.toString()) + put( + JtxContract.JtxCollection.DISPLAYNAME, + info.displayName ?: info.url.lastSegment + ) + put(JtxContract.JtxCollection.DESCRIPTION, info.description) + if (owner != null) + put(JtxContract.JtxCollection.OWNER, owner.url.toString()) + else + Logger.getGlobal().warning("No collection owner given. Will create jtx collection without owner") + put(JtxContract.JtxCollection.OWNER_DISPLAYNAME, owner?.displayName) + if (withColor && info.color != null) + put(JtxContract.JtxCollection.COLOR, info.color) + put(JtxContract.JtxCollection.SUPPORTSVEVENT, info.supportsVEVENT) + put(JtxContract.JtxCollection.SUPPORTSVJOURNAL, info.supportsVJOURNAL) + put(JtxContract.JtxCollection.SUPPORTSVTODO, info.supportsVTODO) + put(JtxContract.JtxCollection.ACCOUNT_NAME, account.name) + put(JtxContract.JtxCollection.ACCOUNT_TYPE, account.type) + put(JtxContract.JtxCollection.READONLY, info.forceReadOnly || !info.privWriteContent) + } + } + + + override fun getAll(account: Account, provider: ContentProviderClient): List = + JtxCollection.find(account, provider, context, LocalJtxCollection.Factory, null, null) + + + override fun update(provider: ContentProviderClient, localCollection: LocalJtxCollection, fromCollection: Collection) { + val accountSettings = accountSettingsFactory.create(localCollection.account) + val values = valuesFromCollection(fromCollection, account = localCollection.account, withColor = accountSettings.getManageCalendarColors()) + localCollection.update(values) + } + + + override fun delete(localCollection: LocalJtxCollection) { + localCollection.delete() + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTaskList.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTaskList.kt index ee0733b74..f13d43c67 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTaskList.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTaskList.kt @@ -9,11 +9,7 @@ import android.annotation.SuppressLint import android.content.ContentProviderClient import android.content.ContentValues import android.content.Context -import android.net.Uri -import at.bitfire.davdroid.Constants -import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.db.SyncState -import at.bitfire.davdroid.util.DavUtils.lastSegment import at.bitfire.ical4android.DmfsTaskList import at.bitfire.ical4android.DmfsTaskListFactory import at.bitfire.ical4android.TaskProvider @@ -35,18 +31,6 @@ class LocalTaskList private constructor( companion object { - fun create(account: Account, provider: ContentProviderClient, providerName: TaskProvider.ProviderName, info: Collection): Uri { - // If the collection doesn't have a color, use a default color. - if (info.color != null) - info.color = Constants.DAVDROID_GREEN_RGBA - - val values = valuesFromCollectionInfo(info, withColor = true) - values.put(TaskLists.OWNER, account.name) - values.put(TaskLists.SYNC_ENABLED, 1) - values.put(TaskLists.VISIBLE, 1) - return create(account, provider, providerName, values) - } - @SuppressLint("Recycle") @Throws(Exception::class) fun onRenameAccount(context: Context, oldName: String, newName: String) { @@ -61,23 +45,6 @@ class LocalTaskList private constructor( } } - private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues { - val values = ContentValues(3) - values.put(TaskLists._SYNC_ID, info.url.toString()) - values.put(TaskLists.LIST_NAME, - if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName) - - if (withColor && info.color != null) - values.put(TaskLists.LIST_COLOR, info.color) - - if (info.privWriteContent && !info.forceReadOnly) - values.put(TaskListColumns.ACCESS_LEVEL, TaskListColumns.ACCESS_LEVEL_OWNER) - else - values.put(TaskListColumns.ACCESS_LEVEL, TaskListColumns.ACCESS_LEVEL_READ) - - return values - } - } private val logger = Logger.getGlobal() @@ -88,8 +55,6 @@ class LocalTaskList private constructor( accessLevel != TaskListColumns.ACCESS_LEVEL_UNDEFINED && accessLevel <= TaskListColumns.ACCESS_LEVEL_READ - override fun deleteCollection(): Boolean = delete() - override val collectionUrl: String? get() = syncId @@ -126,9 +91,6 @@ class LocalTaskList private constructor( accessLevel = values.getAsInteger(TaskListColumns.ACCESS_LEVEL) } - fun update(info: Collection, updateColor: Boolean) = - update(valuesFromCollectionInfo(info, updateColor)) - override fun findDeleted() = queryTasks(Tasks._DELETED, null) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTaskListStore.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTaskListStore.kt new file mode 100644 index 000000000..1c08d12f7 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTaskListStore.kt @@ -0,0 +1,102 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import at.bitfire.davdroid.Constants +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.util.DavUtils.lastSegment +import at.bitfire.ical4android.DmfsTaskList +import at.bitfire.ical4android.TaskProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ApplicationContext +import org.dmfs.tasks.contract.TaskContract.TaskListColumns +import org.dmfs.tasks.contract.TaskContract.TaskLists +import java.util.logging.Level +import java.util.logging.Logger + +class LocalTaskListStore @AssistedInject constructor( + @Assisted authority: String, + val accountSettingsFactory: AccountSettings.Factory, + @ApplicationContext val context: Context, + val db: AppDatabase, + val logger: Logger +): LocalDataStore { + + @AssistedFactory + interface Factory { + fun create(authority: String): LocalTaskListStore + } + + private val serviceDao = db.serviceDao() + + private val providerName = TaskProvider.ProviderName.fromAuthority(authority) + + + override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalTaskList? { + val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection") + val account = Account(service.accountName, context.getString(R.string.account_type)) + + logger.log(Level.INFO, "Adding local task list", fromCollection) + val uri = create(account, provider, providerName, fromCollection) + return DmfsTaskList.findByID(account, provider, providerName, LocalTaskList.Factory, ContentUris.parseId(uri)) + } + + private fun create(account: Account, provider: ContentProviderClient, providerName: TaskProvider.ProviderName, info: Collection): Uri { + // If the collection doesn't have a color, use a default color. + if (info.color != null) + info.color = Constants.DAVDROID_GREEN_RGBA + + val values = valuesFromCollectionInfo(info, withColor = true) + values.put(TaskLists.OWNER, account.name) + values.put(TaskLists.SYNC_ENABLED, 1) + values.put(TaskLists.VISIBLE, 1) + return DmfsTaskList.Companion.create(account, provider, providerName, values) + } + + private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues { + val values = ContentValues(3) + values.put(TaskLists._SYNC_ID, info.url.toString()) + values.put(TaskLists.LIST_NAME, + if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName) + + if (withColor && info.color != null) + values.put(TaskLists.LIST_COLOR, info.color) + + if (info.privWriteContent && !info.forceReadOnly) + values.put(TaskListColumns.ACCESS_LEVEL, TaskListColumns.ACCESS_LEVEL_OWNER) + else + values.put(TaskListColumns.ACCESS_LEVEL, TaskListColumns.ACCESS_LEVEL_READ) + + return values + } + + + override fun getAll(account: Account, provider: ContentProviderClient) = + DmfsTaskList.find(account, LocalTaskList.Factory, provider, providerName, null, null) + + + override fun update(provider: ContentProviderClient, localCollection: LocalTaskList, fromCollection: Collection) { + logger.log(Level.FINE, "Updating local task list ${fromCollection.url}", fromCollection) + val accountSettings = accountSettingsFactory.create(localCollection.account) + localCollection.update(valuesFromCollectionInfo(fromCollection, withColor = accountSettings.getManageCalendarColors())) + } + + + override fun delete(localCollection: LocalTaskList) { + localCollection.delete() + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettingsMigrations.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettingsMigrations.kt index 91558b94f..a15f65051 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettingsMigrations.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettingsMigrations.kt @@ -24,6 +24,7 @@ import at.bitfire.davdroid.db.Service import at.bitfire.davdroid.repository.DavCollectionRepository import at.bitfire.davdroid.repository.DavServiceRepository import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.davdroid.resource.LocalAddressBookStore import at.bitfire.davdroid.resource.LocalTask import at.bitfire.davdroid.sync.SyncUtils import at.bitfire.davdroid.sync.TasksAppManager @@ -53,6 +54,7 @@ class AccountSettingsMigrations @AssistedInject constructor( @ApplicationContext val context: Context, private val collectionRepository: DavCollectionRepository, private val db: AppDatabase, + private val localAddressBookStore: LocalAddressBookStore, private val localAddressBookFactory: LocalAddressBook.Factory, private val logger: Logger, private val serviceRepository: DavServiceRepository, @@ -97,11 +99,11 @@ class AccountSettingsMigrations @AssistedInject constructor( for (oldAddressBookAccount in oldAddressBookAccounts) { // Old address books only have a URL, so use it to determine the collection ID logger.info("Migrating address book ${oldAddressBookAccount.name}") + val oldAddressBook = localAddressBookFactory.create(oldAddressBookAccount, provider) val url = accountManager.getUserData(oldAddressBookAccount, LocalAddressBook.USER_DATA_URL) collectionRepository.getByServiceAndUrl(service.id, url)?.let { collection -> // Set collection ID and rename the account - val localAddressBook = localAddressBookFactory.create(oldAddressBookAccount, provider) - localAddressBook.update(collection, /* read-only flag will be updated at next sync */ forceReadOnly = false) + localAddressBookStore.update(provider, oldAddressBook, collection) } } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/AddressBookSyncer.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/AddressBookSyncer.kt index 9d256cf2d..a83c44c4f 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/AddressBookSyncer.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/AddressBookSyncer.kt @@ -11,8 +11,7 @@ import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.db.Service import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.resource.LocalAddressBook -import at.bitfire.davdroid.settings.Settings -import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.resource.LocalAddressBookStore import at.bitfire.davdroid.util.setAndVerifyUserData import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -26,20 +25,16 @@ class AddressBookSyncer @AssistedInject constructor( @Assisted account: Account, @Assisted extras: Array, @Assisted syncResult: SyncResult, - private val contactsSyncManagerFactory: ContactsSyncManager.Factory, - settingsManager: SettingsManager -): Syncer(account, extras, syncResult) { + addressBookStore: LocalAddressBookStore, + private val contactsSyncManagerFactory: ContactsSyncManager.Factory +): Syncer(account, extras, syncResult) { @AssistedFactory interface Factory { fun create(account: Account, extras: Array, syncResult: SyncResult): AddressBookSyncer } - companion object { - const val PREVIOUS_GROUP_METHOD = "previous_group_method" - } - - private val forceAllReadOnly = settingsManager.getBoolean(Settings.FORCE_READ_ONLY_ADDRESSBOOKS) + override val dataStore = addressBookStore override val serviceType: String get() = Service.TYPE_CARDDAV @@ -47,31 +42,9 @@ class AddressBookSyncer @AssistedInject constructor( get() = ContactsContract.AUTHORITY // Address books use the contacts authority for sync - override fun getLocalCollections(provider: ContentProviderClient): List = - serviceRepository.getByAccountAndType(account.name, serviceType)?.let { service -> - // Get _all_ address books; Otherwise address book accounts of unchecked address books will not be removed - collectionRepository.getByService(service.id).mapNotNull { collection -> - LocalAddressBook.findByCollection(context, provider, collection.id) - } - }.orEmpty() - override fun getDbSyncCollections(serviceId: Long): List = collectionRepository.getByServiceAndSync(serviceId) - override fun update(localCollection: LocalAddressBook, remoteCollection: Collection) { - try { - logger.log(Level.FINE, "Updating local address book ${remoteCollection.url}", remoteCollection) - localCollection.update(remoteCollection, forceAllReadOnly) - } catch (e: Exception) { - logger.log(Level.WARNING, "Couldn't rename address book account", e) - } - } - - override fun create(provider: ContentProviderClient, remoteCollection: Collection): LocalAddressBook { - logger.log(Level.INFO, "Adding local address book", remoteCollection) - return LocalAddressBook.create(context, provider, remoteCollection, forceAllReadOnly) - } - override fun syncCollection(provider: ContentProviderClient, localCollection: LocalAddressBook, remoteCollection: Collection) { logger.info("Synchronizing address book: ${localCollection.addressBookAccount.name}") syncAddressBook( @@ -133,4 +106,11 @@ class AddressBookSyncer @AssistedInject constructor( logger.info("Contacts sync complete") } + + companion object { + + const val PREVIOUS_GROUP_METHOD = "previous_group_method" + + } + } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncer.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncer.kt index 7aa9d69a3..e481f49fc 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncer.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncer.kt @@ -6,16 +6,15 @@ package at.bitfire.davdroid.sync import android.accounts.Account import android.content.ContentProviderClient -import android.content.ContentUris import android.provider.CalendarContract import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.db.Service import at.bitfire.davdroid.resource.LocalCalendar +import at.bitfire.davdroid.resource.LocalCalendarStore import at.bitfire.ical4android.AndroidCalendar import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import java.util.logging.Level /** * Sync logic for calendars @@ -24,23 +23,23 @@ class CalendarSyncer @AssistedInject constructor( @Assisted account: Account, @Assisted extras: Array, @Assisted syncResult: SyncResult, + calendarStore: LocalCalendarStore, private val calendarSyncManagerFactory: CalendarSyncManager.Factory -): Syncer(account, extras, syncResult) { +): Syncer(account, extras, syncResult) { @AssistedFactory interface Factory { fun create(account: Account, extras: Array, syncResult: SyncResult): CalendarSyncer } + override val dataStore = calendarStore + override val serviceType: String get() = Service.TYPE_CALDAV override val authority: String get() = CalendarContract.AUTHORITY - override fun getLocalCollections(provider: ContentProviderClient): List - = AndroidCalendar.find(account, provider, LocalCalendar.Factory, "${CalendarContract.Calendars.SYNC_EVENTS}!=0", null) - override fun prepare(provider: ContentProviderClient): Boolean { // Update colors if (accountSettings.getEventColors()) @@ -69,15 +68,4 @@ class CalendarSyncer @AssistedInject constructor( syncManager.performSync() } - override fun update(localCollection: LocalCalendar, remoteCollection: Collection) { - logger.log(Level.FINE, "Updating local calendar ${remoteCollection.url}", remoteCollection) - localCollection.update(remoteCollection, accountSettings.getManageCalendarColors()) - } - - override fun create(provider: ContentProviderClient, remoteCollection: Collection): LocalCalendar { - logger.log(Level.INFO, "Adding local calendar", remoteCollection) - val uri = LocalCalendar.create(account, provider, remoteCollection) - return AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri)) - } - } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncer.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncer.kt index 1624b06e5..d798a19d1 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncer.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncer.kt @@ -7,19 +7,15 @@ package at.bitfire.davdroid.sync import android.accounts.Account import android.accounts.AccountManager import android.content.ContentProviderClient -import android.content.ContentUris import android.os.Build import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.db.Service -import at.bitfire.davdroid.repository.PrincipalRepository import at.bitfire.davdroid.resource.LocalJtxCollection -import at.bitfire.ical4android.JtxCollection +import at.bitfire.davdroid.resource.LocalJtxCollectionStore import at.bitfire.ical4android.TaskProvider -import at.techbee.jtx.JtxContract import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import java.util.logging.Level /** * Sync logic for jtx board @@ -28,25 +24,24 @@ class JtxSyncer @AssistedInject constructor( @Assisted account: Account, @Assisted extras: Array, @Assisted syncResult: SyncResult, + localJtxCollectionStore: LocalJtxCollectionStore, private val jtxSyncManagerFactory: JtxSyncManager.Factory, - private val principalRepository: PrincipalRepository, private val tasksAppManager: dagger.Lazy -): Syncer(account, extras, syncResult) { +): Syncer(account, extras, syncResult) { @AssistedFactory interface Factory { fun create(account: Account, extras: Array, syncResult: SyncResult): JtxSyncer } + override val dataStore = localJtxCollectionStore + override val serviceType: String get() = Service.TYPE_CALDAV override val authority: String get() = TaskProvider.ProviderName.JtxBoard.authority - override fun getLocalCollections(provider: ContentProviderClient): List - = JtxCollection.find(account, provider, context, LocalJtxCollection.Factory, null, null) - override fun prepare(provider: ContentProviderClient): Boolean { // check whether jtx Board is new enough try { @@ -72,26 +67,6 @@ class JtxSyncer @AssistedInject constructor( override fun getDbSyncCollections(serviceId: Long): List = collectionRepository.getSyncJtxCollections(serviceId) - override fun update(localCollection: LocalJtxCollection, remoteCollection: Collection) { - logger.log(Level.FINE, "Updating local jtx collection ${remoteCollection.url}", remoteCollection) - val owner = remoteCollection.ownerId?.let { principalRepository.get(it) } - localCollection.updateCollection(remoteCollection, owner, accountSettings.getManageCalendarColors()) - } - - override fun create(provider: ContentProviderClient, remoteCollection: Collection): LocalJtxCollection { - logger.log(Level.INFO, "Adding local jtx collection", remoteCollection) - val owner = remoteCollection.ownerId?.let { principalRepository.get(it) } - val uri = LocalJtxCollection.create(account, provider, remoteCollection, owner) - return JtxCollection.find( - account, - provider, - context, - LocalJtxCollection.Factory, - "${JtxContract.JtxCollection.ID} = ?", - arrayOf("${ContentUris.parseId(uri)}") - ).first() - } - override fun syncCollection(provider: ContentProviderClient, localCollection: LocalJtxCollection, remoteCollection: Collection) { logger.info("Synchronizing jtx collection $localCollection") diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/Syncer.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/Syncer.kt index e83bbbd88..350657048 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/Syncer.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/Syncer.kt @@ -15,6 +15,7 @@ import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.repository.DavCollectionRepository import at.bitfire.davdroid.repository.DavServiceRepository import at.bitfire.davdroid.resource.LocalCollection +import at.bitfire.davdroid.resource.LocalDataStore import at.bitfire.davdroid.settings.AccountSettings import dagger.hilt.android.qualifiers.ApplicationContext import okhttp3.HttpUrl @@ -29,7 +30,7 @@ import javax.inject.Inject * * Contains generic sync code, equal for all sync authorities. */ -abstract class Syncer>( +abstract class Syncer, CollectionType: LocalCollection<*>>( protected val account: Account, protected val extras: Array, protected val syncResult: SyncResult @@ -58,6 +59,8 @@ abstract class Syncer>( } + abstract val dataStore: StoreType + @Inject lateinit var accountSettingsFactory: AccountSettings.Factory @@ -95,7 +98,7 @@ abstract class Syncer>( // Find collections in database and provider which should be synced (are sync-enabled) val dbCollections = getSyncEnabledCollections() - val localCollections = getLocalCollections(provider) + val localCollections = dataStore.getAll(account, provider) // Create/update/delete local collections according to DB val updatedLocalCollections = updateCollections(provider, localCollections, dbCollections) @@ -148,12 +151,12 @@ abstract class Syncer>( if (dbCollection == null) { // Collection not available in db = on server (anymore), delete and remove from the updated list logger.fine("Deleting local collection ${localCollection.title}") - localCollection.deleteCollection() + dataStore.delete(localCollection) updatedLocalCollections -= localCollection } else { // Collection exists locally, update local collection and remove it from "to be created" map logger.fine("Updating local collection ${localCollection.title} with $dbCollection") - update(localCollection, dbCollection) + dataStore.update(provider, localCollection, dbCollection) newDbCollections -= dbCollection.url } } @@ -183,7 +186,10 @@ abstract class Syncer>( provider: ContentProviderClient, dbCollections: List ): List = - dbCollections.map { collection -> create(provider, collection) } + dbCollections.map { collection -> + dataStore.create(provider, collection) + ?: throw IllegalStateException("Couldn't create local collection for $collection") + } /** * Synchronize the actual collection contents. @@ -211,17 +217,6 @@ abstract class Syncer>( */ open fun prepare(provider: ContentProviderClient): Boolean = true - /** - * Gets all local collections (not from the database, but from the content provider). - * - * [Syncer] will remove collections which are returned by this method, but not by - * [getDbSyncCollections], and add collections which are returned by [getDbSyncCollections], but not by this method. - * - * @param provider Content provider to access local collections - * @return Local collections to be updated - */ - abstract fun getLocalCollections(provider: ContentProviderClient): List - /** * Get the local database collections which are sync-enabled (should by synchronized). * @@ -233,22 +228,6 @@ abstract class Syncer>( */ abstract fun getDbSyncCollections(serviceId: Long): List - /** - * Updates an existing local collection (in the content provider) with remote collection information (from the DB). - * - * @param localCollection The local collection to be updated - * @param remoteCollection The new remote collection information - */ - abstract fun update(localCollection: CollectionType, remoteCollection: Collection) - - /** - * Creates a new local collection (in the content provider) from remote collection information (from the DB). - * - * @param provider The content provider client to create the local collection - * @param remoteCollection The remote collection to be created locally - */ - abstract fun create(provider: ContentProviderClient, remoteCollection: Collection): CollectionType - /** * Synchronizes local with remote collection contents. * diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/TaskSyncer.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/TaskSyncer.kt index ef5625b3a..409933222 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/TaskSyncer.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/TaskSyncer.kt @@ -7,18 +7,15 @@ package at.bitfire.davdroid.sync import android.accounts.Account import android.accounts.AccountManager import android.content.ContentProviderClient -import android.content.ContentUris import android.os.Build import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.db.Service import at.bitfire.davdroid.resource.LocalTaskList -import at.bitfire.ical4android.DmfsTaskList +import at.bitfire.davdroid.resource.LocalTaskListStore import at.bitfire.ical4android.TaskProvider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import org.dmfs.tasks.contract.TaskContract.TaskLists -import java.util.logging.Level /** * Sync logic for tasks in CalDAV collections ({@code VTODO}). @@ -28,9 +25,10 @@ class TaskSyncer @AssistedInject constructor( @Assisted override val authority: String, @Assisted extras: Array, @Assisted syncResult: SyncResult, + private val localTaskListStoreFactory: LocalTaskListStore.Factory, private val tasksAppManager: dagger.Lazy, private val tasksSyncManagerFactory: TasksSyncManager.Factory, -): Syncer(account, extras, syncResult) { +): Syncer(account, extras, syncResult) { @AssistedFactory interface Factory { @@ -39,11 +37,11 @@ class TaskSyncer @AssistedInject constructor( private val providerName = TaskProvider.ProviderName.fromAuthority(authority) + override val dataStore = localTaskListStoreFactory.create(authority) + override val serviceType: String get() = Service.TYPE_CALDAV - override fun getLocalCollections(provider: ContentProviderClient): List - = DmfsTaskList.find(account, LocalTaskList.Factory, provider, providerName, "${TaskLists.SYNC_ENABLED}!=0", null) override fun prepare(provider: ContentProviderClient): Boolean { // Don't sync if task provider is too old @@ -70,17 +68,6 @@ class TaskSyncer @AssistedInject constructor( override fun getDbSyncCollections(serviceId: Long): List = collectionRepository.getSyncTaskLists(serviceId) - override fun update(localCollection: LocalTaskList, remoteCollection: Collection) { - logger.log(Level.FINE, "Updating local task list ${remoteCollection.url}", remoteCollection) - localCollection.update(remoteCollection, accountSettings.getManageCalendarColors()) - } - - override fun create(provider: ContentProviderClient, remoteCollection: Collection): LocalTaskList { - logger.log(Level.INFO, "Adding local task list", remoteCollection) - val uri = LocalTaskList.create(account, provider, providerName, remoteCollection) - return DmfsTaskList.findByID(account, provider, providerName, LocalTaskList.Factory, ContentUris.parseId(uri)) - } - override fun syncCollection(provider: ContentProviderClient, localCollection: LocalTaskList, remoteCollection: Collection) { logger.info("Synchronizing task list #${localCollection.id} [${localCollection.syncId}]") diff --git a/app/src/main/kotlin/at/bitfire/davdroid/util/CompatUtils.kt b/app/src/main/kotlin/at/bitfire/davdroid/util/CompatUtils.kt index 1afcc310b..ba98c016b 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/util/CompatUtils.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/util/CompatUtils.kt @@ -17,10 +17,13 @@ import java.util.logging.Logger */ fun AccountManager.setAndVerifyUserData(account: Account, key: String, value: String?) { for (i in 1..10) { - setUserData(account, key, value) if (getUserData(account, key) == value) - return /* success */ + /* already set / success */ + return + + setUserData(account, key, value) + // wait a bit because AccountManager access sometimes seems a bit asynchronous Thread.sleep(100) } Logger.getGlobal().warning("AccountManager failed to set $account user data $key := $value") diff --git a/gradle.properties b/gradle.properties index 4755d3130..37b6618e6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,4 +13,7 @@ android.enableR8.fullMode=false # org.gradle.configuration-cache.problems=warn # https://docs.gradle.org/current/userguide/build_cache.html -# org.gradle.caching=true \ No newline at end of file +# org.gradle.caching=true + +# temporary fix for https://github.com/google/ksp/issues/2072 +ksp.incremental=false \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8c64de057..3768907db 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,7 +32,7 @@ hilt = "2.52" kotlin = "2.0.21" kotlinx-coroutines = "1.9.0" # see https://github.com/google/ksp/releases for version numbers -ksp = "2.0.21-1.0.25" +ksp = "2.0.21-1.0.26" mikepenz-aboutLibraries = "11.2.3" nsk90-kstatemachine = "0.31.1" mockk = "1.13.13"