New setting to preselect only personal collections (bitfireAT/davx5#276)

* Add new preselect_collections option and deprecate sync_all_collections option

* Optimize imports

* At refresh, decide on whether a collection should be preselected

* Add preselect_collections_blacklist setting and restriction

* Adhere to preselect_collections_blacklist setting

* Add preselect_collections values

* Also check for empty regex string and use new setting values

* Add unit tests

* Remove sync_all_collections setting and restriction

* Blacklist nextclouds recently contacted addressbook in restriction and setting by default

* Improve kdoc

* KDoc, changed setting names, minor code optimizations

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
This commit is contained in:
Sunik Kupfer 2023-06-06 14:45:12 +02:00 committed by Ricki Hirner
parent 3b2246e74b
commit caf7be5e11
No known key found for this signature in database
GPG key ID: 79A019FCAAEDD3AA
4 changed files with 254 additions and 22 deletions

View file

@ -14,22 +14,34 @@ import androidx.work.WorkManager
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.TestUtils.workScheduledOrRunning
import at.bitfire.davdroid.db.*
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Principal
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.ui.setup.LoginModel
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.mockk
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.apache.commons.lang3.StringUtils
import org.junit.*
import org.junit.Assert.*
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
import org.junit.Test
import java.net.URI
import javax.inject.Inject
@ -514,6 +526,147 @@ class RefreshCollectionsWorkerTest {
assertEquals(0, principals.size)
}
// Others
@Test
fun shouldPreselect_none() {
val service = createTestService(Service.TYPE_CARDDAV)!!
val settings = mockk<SettingsManager>()
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("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
val homesets = listOf(
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
)
val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_all() {
val service = createTestService(Service.TYPE_CARDDAV)!!
val settings = mockk<SettingsManager>()
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("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
val homesets = listOf(
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
)
val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
assertTrue(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_all_blacklisted() {
val service = createTestService(Service.TYPE_CARDDAV)!!
val url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
val settings = mockk<SettingsManager>()
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns url.toString()
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = url
)
val homesets = listOf(
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
)
val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_personal_notPersonal() {
val service = createTestService(Service.TYPE_CARDDAV)!!
val settings = mockk<SettingsManager>()
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("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
val homesets = listOf(
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
)
val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_personal_isPersonal() {
val service = createTestService(Service.TYPE_CARDDAV)!!
val settings = mockk<SettingsManager>()
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("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
val homesets = listOf(
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
)
val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
assertTrue(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_personal_isPersonalButBlacklisted() {
val service = createTestService(Service.TYPE_CARDDAV)!!
val collectionUrl = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
val settings = mockk<SettingsManager>()
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns collectionUrl.toString()
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = collectionUrl
)
val homesets = listOf(
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
)
val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
// Test helpers and dependencies

View file

@ -12,15 +12,45 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.hilt.work.HiltWorker
import androidx.lifecycle.map
import androidx.work.*
import at.bitfire.dav4jvm.*
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.MultiResponseCallback
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.*
import at.bitfire.dav4jvm.property.AddressbookDescription
import at.bitfire.dav4jvm.property.AddressbookHomeSet
import at.bitfire.dav4jvm.property.CalendarColor
import at.bitfire.dav4jvm.property.CalendarDescription
import at.bitfire.dav4jvm.property.CalendarHomeSet
import at.bitfire.dav4jvm.property.CalendarProxyReadFor
import at.bitfire.dav4jvm.property.CalendarProxyWriteFor
import at.bitfire.dav4jvm.property.CurrentUserPrivilegeSet
import at.bitfire.dav4jvm.property.DisplayName
import at.bitfire.dav4jvm.property.GroupMembership
import at.bitfire.dav4jvm.property.HrefListProperty
import at.bitfire.dav4jvm.property.Owner
import at.bitfire.dav4jvm.property.ResourceType
import at.bitfire.dav4jvm.property.Source
import at.bitfire.dav4jvm.property.SupportedAddressData
import at.bitfire.dav4jvm.property.SupportedCalendarComponentSet
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.*
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.log.Logger
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker.Companion.ARG_SERVICE_ID
import at.bitfire.davdroid.settings.AccountSettings
@ -84,7 +114,7 @@ class RefreshCollectionsWorker @AssistedInject constructor(
/**
* Uniquely identifies a refresh worker. Useful for stopping work, or querying its state.
*
*
* @param serviceId what service (CalDAV/CardDAV) the worker is running for
*/
fun workerName(serviceId: Long): String = "$REFRESH_COLLECTIONS_WORKER_TAG-$serviceId"
@ -276,8 +306,9 @@ class RefreshCollectionsWorker @AssistedInject constructor(
for (href in homeSet.hrefs)
dav.location.resolve(href)?.let {
val foundUrl = UrlUtils.withTrailingSlash(it)
// Save the homeset - personal if outer call of recursion
db.homeSetDao().insertOrUpdateByUrl(HomeSet(0, service.id, forPersonalHomeset, foundUrl))
db.homeSetDao().insertOrUpdateByUrl(
HomeSet(0, service.id, forPersonalHomeset, foundUrl)
)
}
}
@ -351,7 +382,7 @@ class RefreshCollectionsWorker @AssistedInject constructor(
collection.serviceId = service.id
collection.homeSetId = localHomeset.id
collection.sync = settings.getBoolean(Settings.SYNC_ALL_COLLECTIONS)
collection.sync = shouldPreselect(collection, homesets.values)
// .. and save the principal url (collection owner)
response[Owner::class.java]?.href
@ -467,6 +498,49 @@ class RefreshCollectionsWorker @AssistedInject constructor(
(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 home-sets (to check whether collection is in a personal home-set)
* @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
}
}
}
}

View file

@ -4,13 +4,7 @@
package at.bitfire.davdroid.settings
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import android.os.Build
import androidx.core.content.getSystemService
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -26,11 +20,11 @@ class DefaultsProvider(
override val booleanDefaults = mutableMapOf(
Pair(Settings.DISTRUST_SYSTEM_CERTIFICATES, false),
Pair(Settings.SYNC_ALL_COLLECTIONS, false),
Pair(Settings.FORCE_READ_ONLY_ADDRESSBOOKS, false)
)
override val intDefaults = mapOf(
Pair(Settings.PRESELECT_COLLECTIONS, Settings.PRESELECT_COLLECTIONS_NONE),
Pair(Settings.PROXY_TYPE, Settings.PROXY_TYPE_SYSTEM),
Pair(Settings.PROXY_PORT, 9050) // Orbot SOCKS
)
@ -40,7 +34,8 @@ class DefaultsProvider(
)
override val stringDefaults = mapOf(
Pair(Settings.PROXY_HOST, "localhost")
Pair(Settings.PROXY_HOST, "localhost"),
Pair(Settings.PRESELECT_COLLECTIONS_EXCLUDED, "/z-app-generated--contactsinteraction--recent/") // Nextcloud "Recently Contacted" address book
)
class Factory @Inject constructor(): SettingsProviderFactory {

View file

@ -39,8 +39,18 @@ object Settings {
const val PREFERRED_TASKS_PROVIDER = "preferred_tasks_provider"
/** whether detected collections are selected for synchronization for default */
const val SYNC_ALL_COLLECTIONS = "sync_all_collections"
/** whether collections are automatically selected for synchronization after their initial detection */
const val PRESELECT_COLLECTIONS = "preselect_collections"
/** collections are not automatically selected for synchronization */
const val PRESELECT_COLLECTIONS_NONE = 0
/** all collections (except those matching [PRESELECT_COLLECTIONS_EXCLUDED]) are automatically selected for synchronization */
const val PRESELECT_COLLECTIONS_ALL = 1
/** personal collections (except those matching [PRESELECT_COLLECTIONS_EXCLUDED]) are automatically selected for synchronization */
const val PRESELECT_COLLECTIONS_PERSONAL = 2
/** regular expression to match URLs of collections to be excluded from pre-selection */
const val PRESELECT_COLLECTIONS_EXCLUDED = "preselect_collections_excluded"
/** whether all address books are forced to be read-only */
const val FORCE_READ_ONLY_ADDRESSBOOKS = "force_read_only_addressbooks"