Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Unverified Commit 5f80c8e7 authored by Ricki Hirner's avatar Ricki Hirner Committed by GitHub
Browse files

Add migration for Syncer URL → ID change (#1285)

* Add AccountSettingsMigration20 to handle collection ID migration for syncer

* Add success path tests for address books and calendars

* Increase CURRENT_VERSION, fix task list store
parent 5ece438b
Loading
Loading
Loading
Loading
+142 −0
Original line number Diff line number Diff line
/*
 * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
 */

package at.bitfire.davdroid.settings.migration

import android.Manifest
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import android.provider.CalendarContract
import android.provider.CalendarContract.Calendars
import androidx.core.content.contentValuesOf
import androidx.core.database.getLongOrNull
import androidx.test.rule.GrantPermissionRule
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalCalendarStore
import at.bitfire.davdroid.resource.LocalTestAddressBook
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.junit4.MockKRule
import io.mockk.mockk
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject

@HiltAndroidTest
class AccountSettingsMigration20Test {

    @Inject
    lateinit var calendarStore: LocalCalendarStore

    @Inject @ApplicationContext
    lateinit var context: Context

    @Inject
    lateinit var db: AppDatabase

    @Inject
    lateinit var migration: AccountSettingsMigration20

    @get:Rule
    val hiltRule = HiltAndroidRule(this)

    @get:Rule
    val mockkRule = MockKRule(this)

    @get:Rule
    val permissionsRule = GrantPermissionRule.grant(
        Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS,
        Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR
    )

    val accountManager by lazy { AccountManager.get(context) }

    @Before
    fun setUp() {
        hiltRule.inject()
    }


    @Test
    fun testMigrateAddressBooks_UrlMatchesCollection() {
        // set up legacy address-book with URL, but without collection ID
        val account = Account("test", "test")
        val url = "https://example.com/"

        db.serviceDao().insertOrReplace(Service(id = 1, accountName = account.name, type = Service.TYPE_CARDDAV, principal = null))
        val collectionId = db.collectionDao().insert(Collection(
            serviceId = 1,
            type = Collection.Companion.TYPE_ADDRESSBOOK,
            url = url.toHttpUrl()
        ))

        val addressBook = LocalTestAddressBook.create(context, account, mockk())
        try {
            accountManager.setAndVerifyUserData(addressBook.addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME, account.name)
            accountManager.setAndVerifyUserData(addressBook.addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE, account.type)
            accountManager.setAndVerifyUserData(addressBook.addressBookAccount, AccountSettingsMigration20.ADDRESS_BOOK_USER_DATA_URL, url)
            accountManager.setAndVerifyUserData(addressBook.addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID, null)

            migration.migrateAddressBooks(account, cardDavServiceId = 1)

            assertEquals(
                collectionId,
                accountManager.getUserData(addressBook.addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID).toLongOrNull()
            )
        } finally {
            addressBook.remove()
        }
    }


    @Test
    fun testMigrateCalendars_UrlMatchesCollection() {
        // set up legacy calendar with URL, but without collection ID
        val account = Account("test", CalendarContract.ACCOUNT_TYPE_LOCAL)
        val url = "https://example.com/"

        db.serviceDao().insertOrReplace(Service(id = 1, accountName = account.name, type = Service.TYPE_CALDAV, principal = null))
        val collectionId = db.collectionDao().insert(
            Collection(
                serviceId = 1,
                type = Collection.Companion.TYPE_CALENDAR,
                url = url.toHttpUrl()
            )
        )

        context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!.use { provider ->
            val uri = provider.insert(
                Calendars.CONTENT_URI.asSyncAdapter(account),
                contentValuesOf(
                    Calendars.ACCOUNT_NAME to account.name,
                    Calendars.ACCOUNT_TYPE to account.type,
                    Calendars.CALENDAR_DISPLAY_NAME to "Test",
                    Calendars.NAME to url,
                    Calendars.SYNC_EVENTS to 1
                )
            )!!
            try {
                migration.migrateCalendars(account, calDavServiceId = 1)

                provider.query(uri.asSyncAdapter(account), arrayOf(Calendars._SYNC_ID), null, null, null)!!.use { cursor ->
                    cursor.moveToNext()
                    assertEquals(collectionId, cursor.getLongOrNull(0))
                }
            } finally {
                provider.delete(uri.asSyncAdapter(account), null, null)
            }
        }
    }

}
 No newline at end of file
+1 −1
Original line number Diff line number Diff line
@@ -356,7 +356,7 @@ class AccountSettings @AssistedInject constructor(

    companion object {

        const val CURRENT_VERSION = 19
        const val CURRENT_VERSION = 20
        const val KEY_SETTINGS_VERSION = "version"

        const val KEY_SYNC_INTERVAL_ADDRESSBOOKS = "sync_interval_addressbooks"
+144 −0
Original line number Diff line number Diff line
/*
 * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
 */

package at.bitfire.davdroid.settings.migration

import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import android.provider.CalendarContract.Calendars
import androidx.annotation.OpenForTesting
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.resource.LocalAddressBookStore
import at.bitfire.davdroid.resource.LocalCalendarStore
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.davdroid.sync.TasksAppManager
import at.bitfire.ical4android.JtxCollection
import at.techbee.jtx.JtxContract
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntKey
import dagger.multibindings.IntoMap
import org.dmfs.tasks.contract.TaskContract.TaskLists
import javax.inject.Inject

/**
 * [at.bitfire.davdroid.sync.Syncer] now users collection IDs instead of URLs to match
 * local and remote (database) collections.
 *
 * This migration writes the database collection IDs to the local collections. If we wouldn't do that,
 * the syncer would not be able to find the correct local collection for a remote collection and
 * all local collections would be deleted and re-created.
 */
class AccountSettingsMigration20 @Inject constructor(
    @ApplicationContext context: Context,
    private val addressBookStore: LocalAddressBookStore,
    private val calendarStore: LocalCalendarStore,
    private val collectionRepository: DavCollectionRepository,
    private val serviceRepository: DavServiceRepository,
    private val tasksAppManager: TasksAppManager
): AccountSettingsMigration {

    val accountManager = AccountManager.get(context)

    override fun migrate(account: Account) {
        serviceRepository.getByAccountAndType(account.name, Service.TYPE_CARDDAV)?.let { cardDavService ->
            migrateAddressBooks(account, cardDavService.id)
        }

        serviceRepository.getByAccountAndType(account.name, Service.TYPE_CALDAV)?.let { calDavService ->
            migrateCalendars(account, calDavService.id)
            migrateTaskLists(account, calDavService.id)
        }
    }

    @OpenForTesting
    internal fun migrateAddressBooks(account: Account, cardDavServiceId: Long) {
        try {
            addressBookStore.acquireContentProvider()
        } catch (_: SecurityException) {
            // no contacts permission
            null
        }?.use { provider ->
            for (addressBook in addressBookStore.getAll(account, provider)) {
                val url = accountManager.getUserData(addressBook.addressBookAccount, ADDRESS_BOOK_USER_DATA_URL) ?: continue
                val collection = collectionRepository.getByServiceAndUrl(cardDavServiceId, url) ?: continue
                addressBook.dbCollectionId = collection.id
            }
        }
    }

    @OpenForTesting
    internal fun migrateCalendars(account: Account, calDavServiceId: Long) {
        try {
            calendarStore.acquireContentProvider()
        } catch (_: SecurityException) {
            // no contacts permission
            null
        }?.use { provider ->
            for (calendar in calendarStore.getAll(account, provider))
                provider.query(calendar.calendarSyncURI(), arrayOf(Calendars.NAME), null, null, null)?.use { cursor ->
                    if (cursor.moveToFirst())
                        cursor.getString(0)?.let { url ->
                            collectionRepository.getByServiceAndUrl(calDavServiceId, url)?.let { collection ->
                                calendar.update(contentValuesOf(
                                    Calendars._SYNC_ID to collection.id
                                ))
                            }
                        }
                }
        }
    }

    @OpenForTesting
    internal fun migrateTaskLists(account: Account, calDavServiceId: Long) {
        val taskListStore = tasksAppManager.getDataStore() ?: /* no tasks app */ return
        try {
            taskListStore.acquireContentProvider()
        } catch (_: SecurityException) {
            // no tasks permission
            null
        }?.use { provider ->
            for (taskList in taskListStore.getAll(account, provider)) {
                when (taskList) {
                    is LocalTaskList -> {       // tasks.org, OpenTasks
                        val url = taskList.syncId ?: continue
                        collectionRepository.getByServiceAndUrl(calDavServiceId, url)?.let { collection ->
                            taskList.update(contentValuesOf(
                                TaskLists._SYNC_ID to collection.id.toString()
                            ))
                        }
                    }
                    is JtxCollection<*> -> {    // jtxBoard
                        val url = taskList.url ?: continue
                        collectionRepository.getByServiceAndUrl(calDavServiceId, url)?.let { collection ->
                            taskList.update(contentValuesOf(
                                JtxContract.JtxCollection.SYNC_ID to collection.id
                            ))
                        }
                    }
                }
            }
        }
    }

    companion object {
        internal const val ADDRESS_BOOK_USER_DATA_URL = "url"
    }

    @Module
    @InstallIn(SingletonComponent::class)
    abstract class AccountSettingsMigrationModule {
        @Binds @IntoMap
        @IntKey(20)
        abstract fun provide(impl: AccountSettingsMigration20): AccountSettingsMigration
    }

}
 No newline at end of file