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

Unverified Commit e13c1405 authored by Sunik Kupfer's avatar Sunik Kupfer Committed by GitHub
Browse files

Extract `refreshHomesetsAndTheirCollections` to `HomeSetRefresher` (#1606)



* Extract refreshHomesetsAndTheirCollections to HomeSetRefresher

Signed-off-by: default avatarSunik Kupfer <kupfer@bitfire.at>

* Add kdoc

Signed-off-by: default avatarSunik Kupfer <kupfer@bitfire.at>

* Minor changes

---------

Signed-off-by: default avatarSunik Kupfer <kupfer@bitfire.at>
Co-authored-by: default avatarRicki Hirner <hirner@bitfire.at>
parent cdb50205
Loading
Loading
Loading
Loading
+0 −326
Original line number Diff line number Diff line
@@ -7,26 +7,20 @@ package at.bitfire.davdroid.servicedetection
import android.security.NetworkSecurityPolicy
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.junit4.MockKRule
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assume
import org.junit.Before
import org.junit.Rule
@@ -91,169 +85,6 @@ class CollectionListRefresherTest {
    }


    // refreshHomesetsAndTheirCollections

    @Test
    fun refreshHomesetsAndTheirCollections_addsNewCollection() = runTest {
        // save homeset in DB
        val homesetId = db.homeSetDao().insert(
            HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
        )

        // Refresh
        refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()

        // Check the collection defined in homeset is now in the database
        assertEquals(
            Collection(
                1,
                service.id,
                homesetId,
                1, // will have gotten an owner too
                Collection.TYPE_ADDRESSBOOK,
                mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
                displayName = "My Contacts",
                description = "My Contacts Description"
            ),
            db.collectionDao().getByService(service.id).first()
        )
    }

    @Test
    fun refreshHomesetsAndTheirCollections_updatesExistingCollection() {
        // save "old" collection in DB
        val collectionId = db.collectionDao().insertOrUpdateByUrl(
            Collection(
                0,
                service.id,
                null,
                null,
                Collection.TYPE_ADDRESSBOOK,
                mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
                displayName = "My Contacts",
                description = "My Contacts Description"
            )
        )

        // Refresh
        refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()

        // Check the collection got updated
        assertEquals(
            Collection(
                collectionId,
                service.id,
                null,
                null,
                Collection.TYPE_ADDRESSBOOK,
                mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
                displayName = "My Contacts",
                description = "My Contacts Description"
            ),
            db.collectionDao().get(collectionId)
        )
    }

    @Test
    fun refreshHomesetsAndTheirCollections_preservesCollectionFlags() {
        // save "old" collection in DB - with set flags
        val collectionId = db.collectionDao().insertOrUpdateByUrl(
            Collection(
                0,
                service.id,
                null,
                null,
                Collection.TYPE_ADDRESSBOOK,
                mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
                displayName = "My Contacts",
                description = "My Contacts Description",
                forceReadOnly = true,
                sync = true
            )
        )

        // Refresh
        refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()

        // Check the collection got updated
        assertEquals(
            Collection(
                collectionId,
                service.id,
                null,
                null,
                Collection.TYPE_ADDRESSBOOK,
                mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
                displayName = "My Contacts",
                description = "My Contacts Description",
                forceReadOnly = true,
                sync = true
            ),
            db.collectionDao().get(collectionId)
        )
    }

    @Test
    fun refreshHomesetsAndTheirCollections_marksRemovedCollectionsAsHomeless() {
        // save homeset in DB - which is empty (zero address books) on the serverside
        val homesetId = db.homeSetDao().insert(
            HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_EMPTY"))
        )

        // place collection in DB - as part of the homeset
        val collectionId = db.collectionDao().insertOrUpdateByUrl(
            Collection(
                0,
                service.id,
                homesetId,
                null,
                Collection.TYPE_ADDRESSBOOK,
                mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
            )
        )

        // Refresh - should mark collection as homeless, because serverside homeset is empty.
        refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()

        // Check the collection, is now marked as homeless
        assertEquals(null, db.collectionDao().get(collectionId)!!.homeSetId)
    }

    @Test
    fun refreshHomesetsAndTheirCollections_addsOwnerUrls() {
        // save a homeset in DB
        val homesetId = db.homeSetDao().insert(
            HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
        )

        // place collection in DB - as part of the homeset
        val collectionId = db.collectionDao().insertOrUpdateByUrl(
            Collection(
                0,
                service.id,
                homesetId, // part of above home set
                null,
                Collection.TYPE_ADDRESSBOOK,
                mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
            )
        )

        // Refresh - homesets and their collections
        assertEquals(0, db.principalDao().getByService(service.id).size)
        refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()

        // Check principal saved and the collection was updated with its reference
        val principals = db.principalDao().getByService(service.id)
        assertEquals(1, principals.size)
        assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
        assertEquals(null, principals[0].displayName)
        assertEquals(
            principals[0].id,
            db.collectionDao().get(collectionId)!!.ownerId
        )
    }


    // refreshHomelessCollections

    @Test
@@ -340,163 +171,6 @@ class CollectionListRefresherTest {
    }


    // Others

    @Test
    fun shouldPreselect_none() {
        every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_NONE
        every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""

        val collection = Collection(
            0,
            service.id,
            0,
            type = Collection.TYPE_ADDRESSBOOK,
            url = mockServer.url("/addressbook-homeset/addressbook/")
        )
        val homesets = listOf(
            HomeSet(
                id = 0,
                serviceId = service.id,
                personal = true,
                url = mockServer.url("/addressbook-homeset/")
            )
        )

        val refresher = refresherFactory.create(service, client.okHttpClient)
        assertFalse(refresher.shouldPreselect(collection, homesets))
    }

    @Test
    fun shouldPreselect_all() {
        every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
        every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""

        val collection = Collection(
            0,
            service.id,
            0,
            type = Collection.TYPE_ADDRESSBOOK,
            url = mockServer.url("/addressbook-homeset/addressbook/")
        )
        val homesets = listOf(
            HomeSet(
                id = 0,
                serviceId = service.id,
                personal = false,
                url = mockServer.url("/addressbook-homeset/")
            )
        )

        val refresher = refresherFactory.create(service, client.okHttpClient)
        assertTrue(refresher.shouldPreselect(collection, homesets))
    }

    @Test
    fun shouldPreselect_all_blacklisted() {
        val url = mockServer.url("/addressbook-homeset/addressbook/")

        every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
        every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns url.toString()

        val collection = Collection(
            id = 0,
            serviceId = service.id,
            homeSetId = 0,
            type = Collection.TYPE_ADDRESSBOOK,
            url = url
        )
        val homesets = listOf(
            HomeSet(
                id = 0,
                serviceId = service.id,
                personal = false,
                url = mockServer.url("/addressbook-homeset/")
            )
        )

        val refresher = refresherFactory.create(service, client.okHttpClient)
        assertFalse(refresher.shouldPreselect(collection, homesets))
    }

    @Test
    fun shouldPreselect_personal_notPersonal() {
        every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
        every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""

        val collection = Collection(
            id = 0,
            serviceId = service.id,
            homeSetId = 0,
            type = Collection.TYPE_ADDRESSBOOK,
            url = mockServer.url("/addressbook-homeset/addressbook/")
        )
        val homesets = listOf(
            HomeSet(
                id = 0,
                serviceId = service.id,
                personal = false,
                url = mockServer.url("/addressbook-homeset/")
            )
        )

        val refresher = refresherFactory.create(service, client.okHttpClient)
        assertFalse(refresher.shouldPreselect(collection, homesets))
    }

    @Test
    fun shouldPreselect_personal_isPersonal() {
        every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
        every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""

        val collection = Collection(
            0,
            service.id,
            0,
            type = Collection.TYPE_ADDRESSBOOK,
            url = mockServer.url("/addressbook-homeset/addressbook/")
        )
        val homesets = listOf(
            HomeSet(
                id = 0,
                serviceId = service.id,
                personal = true,
                url = mockServer.url("/addressbook-homeset/")
            )
        )

        val refresher = refresherFactory.create(service, client.okHttpClient)
        assertTrue(refresher.shouldPreselect(collection, homesets))
    }

    @Test
    fun shouldPreselect_personal_isPersonalButBlacklisted() {
        val collectionUrl = mockServer.url("/addressbook-homeset/addressbook/")

        every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
        every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns collectionUrl.toString()

        val collection = Collection(
            id = 0,
            serviceId = service.id,
            homeSetId = 0,
            type = Collection.TYPE_ADDRESSBOOK,
            url = collectionUrl
        )
        val homesets = listOf(
            HomeSet(
                id = 0,
                serviceId = service.id,
                personal = true,
                url = mockServer.url("/addressbook-homeset/")
            )
        )

        val refresher = refresherFactory.create(service, client.okHttpClient)
        assertFalse(refresher.shouldPreselect(collection, homesets))
    }


    companion object {

        private const val PATH_CALDAV = "/caldav"
+473 −0

File added.

Preview size limit exceeded, changes collapsed.

+3 −168
Original line number Diff line number Diff line
@@ -5,36 +5,18 @@
package at.bitfire.davdroid.servicedetection

import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.caldav.CalendarColor
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
import at.bitfire.dav4jvm.property.caldav.CalendarTimezoneId
import at.bitfire.dav4jvm.property.caldav.Source
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
import at.bitfire.dav4jvm.property.push.PushTransports
import at.bitfire.dav4jvm.property.push.Topic
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.Owner
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Principal
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavHomeSetRepository
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import okhttp3.OkHttpClient
import java.util.logging.Level
import java.util.logging.Logger

/**
@@ -46,8 +28,7 @@ class CollectionListRefresher @AssistedInject constructor(
    private val db: AppDatabase,
    private val collectionRepository: DavCollectionRepository,
    private val homeSetRepository: DavHomeSetRepository,
    private val logger: Logger,
    private val settings: SettingsManager
    private val logger: Logger
) {

    @AssistedFactory
@@ -55,101 +36,6 @@ class CollectionListRefresher @AssistedInject constructor(
        fun create(service: Service, httpClient: OkHttpClient): CollectionListRefresher
    }

    /**
     * Collection properties to ask for in a PROPFIND request on a collection.
     */
    private val collectionProperties: Array<Property.Name> =
        arrayOf(                        // generic WebDAV properties
            CurrentUserPrivilegeSet.NAME,
            DisplayName.NAME,
            Owner.NAME,
            ResourceType.NAME,
            PushTransports.NAME,        // WebDAV-Push
            Topic.NAME
        ) + when (service.type) {       // service-specific CalDAV/CardDAV properties
            Service.TYPE_CARDDAV -> arrayOf(
                AddressbookDescription.NAME
            )
            Service.TYPE_CALDAV -> arrayOf(
                CalendarColor.NAME,
                CalendarDescription.NAME,
                CalendarTimezone.NAME,
                CalendarTimezoneId.NAME,
                SupportedCalendarComponentSet.NAME,
                Source.NAME
            )
            else -> throw IllegalArgumentException()
        }

    /**
     * Refreshes home-sets and their collections.
     *
     * Each stored home-set URL is queried (`PROPFIND`) and its collections are either saved, updated
     * or marked as homeless - in case a collection was removed from its home-set.
     *
     * If a home-set URL in fact points to a collection directly, the collection will be saved with this URL,
     * and a null value for it's home-set. Refreshing of collections without home-sets is then handled by [refreshHomelessCollections].
     */
    internal fun refreshHomesetsAndTheirCollections() {
        val homesets = homeSetRepository.getByServiceBlocking(service.id).associateBy { it.url }.toMutableMap()
        for((homeSetUrl, localHomeset) in homesets) {
            logger.fine("Listing home set $homeSetUrl")

            // To find removed collections in this homeset: create a queue from existing collections and remove every collection that
            // is successfully rediscovered. If there are collections left, after processing is done, these are marked homeless.
            val localHomesetCollections = db.collectionDao()
                .getByServiceAndHomeset(service.id, localHomeset.id)
                .associateBy { it.url }
                .toMutableMap()

            try {
                DavResource(httpClient, homeSetUrl).propfind(1, *collectionProperties) { response, relation ->
                    // Note: This callback may be called multiple times ([MultiResponseCallback])
                    if (!response.isSuccess())
                        return@propfind

                    if (relation == Response.HrefRelation.SELF)
                        // this response is about the home set itself
                        homeSetRepository.insertOrUpdateByUrlBlocking(localHomeset.copy(
                            displayName = response[DisplayName::class.java]?.displayName,
                            privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind != false
                        ))

                    // in any case, check whether the response is about a usable collection
                    var collection = Collection.fromDavResponse(response) ?: return@propfind
                    collection = collection.copy(
                        serviceId = service.id,
                        homeSetId = localHomeset.id,
                        sync = shouldPreselect(collection, homesets.values),
                        ownerId = response[Owner::class.java]?.href  // save the principal id (collection owner)
                            ?.let { response.href.resolve(it) }
                            ?.let { principalUrl -> Principal.fromServiceAndUrl(service, principalUrl) }
                            ?.let { principal -> db.principalDao().insertOrUpdate(service.id, principal) }
                    )
                    logger.log(Level.FINE, "Found collection", collection)

                    // save or update collection if usable (ignore it otherwise)
                    if (isUsableCollection(collection))
                        collectionRepository.insertOrUpdateByUrlRememberSync(collection)

                    // Remove this collection from queue - because it was found in the home set
                    localHomesetCollections.remove(collection.url)
                }
            } catch (e: HttpException) {
                // delete home set locally if it was not accessible (40x)
                if (e.code in arrayOf(403, 404, 410))
                    homeSetRepository.deleteBlocking(localHomeset)
            }

            // Mark leftover (not rediscovered) collections from queue as homeless (remove association)
            for ((_, homelessCollection) in localHomesetCollections)
                collectionRepository.insertOrUpdateByUrlRememberSync(
                    homelessCollection.copy(homeSetId = null)
                )

        }
    }

    /**
     * Refreshes collections which don't have a homeset.
     *
@@ -158,6 +44,7 @@ class CollectionListRefresher @AssistedInject constructor(
    internal fun refreshHomelessCollections() {
        val homelessCollections = db.collectionDao().getByServiceAndHomeset(service.id, null).associateBy { it.url }.toMutableMap()
        for((url, localCollection) in homelessCollections) try {
            val collectionProperties = ServiceDetectionUtils.collectionQueryProperties(service.type)
            DavResource(httpClient, url).propfind(0, *collectionProperties) { response, _ ->
                if (!response.isSuccess()) {
                    collectionRepository.delete(localCollection)
@@ -166,7 +53,7 @@ class CollectionListRefresher @AssistedInject constructor(

                // Save or update the collection, if usable, otherwise delete it
                Collection.fromDavResponse(response)?.let { collection ->
                    if (!isUsableCollection(collection))
                    if (!ServiceDetectionUtils.isUsableCollection(service, collection))
                        return@let
                    collectionRepository.insertOrUpdateByUrlRememberSync(collection.copy(
                        serviceId = localCollection.serviceId,          // use same service ID as previous entry
@@ -187,56 +74,4 @@ class CollectionListRefresher @AssistedInject constructor(

    }

    /**
     * Finds out whether given collection is usable, by checking that either
     *  - CalDAV/CardDAV: service and collection type match, or
     *  - WebCal: subscription source URL is not empty
     */
    private fun isUsableCollection(collection: Collection) =
        (service.type == Service.TYPE_CARDDAV && collection.type == Collection.TYPE_ADDRESSBOOK) ||
                (service.type == Service.TYPE_CALDAV && arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(collection.type)) ||
                (collection.type == Collection.TYPE_WEBCAL && collection.source != null)

    /**
     * Whether to preselect the given collection for synchronisation, according to the
     * settings [Settings.PRESELECT_COLLECTIONS] (see there for allowed values) and
     * [Settings.PRESELECT_COLLECTIONS_EXCLUDED].
     *
     * A collection is considered _personal_ if it is found in one of the current-user-principal's home-sets.
     *
     * Before a collection is pre-selected, we check whether its URL matches the regexp in
     * [Settings.PRESELECT_COLLECTIONS_EXCLUDED], in which case *false* is returned.
     *
     * @param collection    the collection to check
     * @param homeSets      list of personal home-sets
     * @return *true* if the collection should be preselected for synchronization; *false* otherwise
     */
    internal fun shouldPreselect(collection: Collection, homeSets: Iterable<HomeSet>): Boolean {
        val shouldPreselect = settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS)

        val excluded by lazy {
            val excludedRegex = settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED)
            if (!excludedRegex.isNullOrEmpty())
                Regex(excludedRegex).containsMatchIn(collection.url.toString())
            else
                false
        }

        return when (shouldPreselect) {
            Settings.PRESELECT_COLLECTIONS_ALL ->
                // preselect if collection url is not excluded
                !excluded

            Settings.PRESELECT_COLLECTIONS_PERSONAL ->
                // preselect if is personal (in a personal home-set), but not excluded
                homeSets
                    .filter { homeset -> homeset.personal }
                    .map { homeset -> homeset.id }
                    .contains(collection.homeSetId)
                    && !excluded

            else -> // don't preselect
                false
        }
    }
}
 No newline at end of file
+162 −0

File added.

Preview size limit exceeded, changes collapsed.

+3 −1
Original line number Diff line number Diff line
@@ -63,6 +63,7 @@ class RefreshCollectionsWorker @AssistedInject constructor(
    @Assisted appContext: Context,
    @Assisted workerParams: WorkerParameters,
    private val collectionListRefresherFactory: CollectionListRefresher.Factory,
    private val homeSetRefresherFactory: HomeSetRefresher.Factory,
    private val httpClientBuilder: HttpClient.Builder,
    private val logger: Logger,
    private val notificationRegistry: NotificationRegistry,
@@ -168,7 +169,8 @@ class RefreshCollectionsWorker @AssistedInject constructor(
                        }

                        // refresh home sets and their member collections
                        refresher.refreshHomesetsAndTheirCollections()
                        homeSetRefresherFactory.create(service, httpClient)
                            .refreshHomesetsAndTheirCollections()

                        // also refresh collections without a home set
                        refresher.refreshHomelessCollections()
Loading