168 refactor service detection part 2 (bitfireAT/davx5#174)

* [WIP] refactor

* [WIP] refactor

* save, update and delete homesets one by one

* save, update and delete collections one by one

* cleaner code

* prevent jumps in row ids

* [WIP] small changes and kdoc

* remove duplicate code and add kdoc

* improve kdoc

* remove redundancy based on service type in resource finder

* tests setup

* handle cancellation

* add tests

* Don't use IdEntity for service detection anymore

* HomeSetDao: getByUrl requires a service (there may be two accounts with the same homeset URLs, for instance with different credentials)

* Deprecate DaoTools

* Minor changes

* Add TODO

* use self explanatory variables instead of a pair

* update kdoc

* add unfinished tests

* add test for updating a collection

* add test for preserving collection flags

* mark collections as homeless if not rediscovered in its homeset

* proper implementation of update and delete of homeless collections with test

* minor changes and kdoc

* Tests: adapt mock server 404

* KDoc

* get collections by service and url, deprecate getByUrl()

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
This commit is contained in:
Sunik Kupfer 2023-01-19 23:13:27 +01:00 committed by Ricki Hirner
parent df2922f873
commit 62617a2889
No known key found for this signature in database
GPG key ID: 79A019FCAAEDD3AA
20 changed files with 1062 additions and 521 deletions

View file

@ -5,10 +5,32 @@
package at.bitfire.davdroid
import android.app.Application
import android.content.Context
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.WorkInfo
import androidx.work.WorkManager
import at.bitfire.davdroid.log.Logger
import com.google.common.util.concurrent.ListenableFuture
object TestUtils {
val targetApplication by lazy { InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application }
fun workScheduledOrRunning(context: Context, workerName: String): Boolean {
val future: ListenableFuture<List<WorkInfo>> = WorkManager.getInstance(context).getWorkInfosForUniqueWork(workerName)
val workInfoList: List<WorkInfo>
try {
workInfoList = future.get()
} catch (e: Exception) {
Logger.log.severe("Failed to retrieve work info list for worker $workerName", )
return false
}
for (workInfo in workInfoList) {
val state = workInfo.state
if (state == WorkInfo.State.RUNNING || state == WorkInfo.State.ENQUEUED)
return true
}
return false
}
}

View file

@ -1,68 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.db
import androidx.room.Room
import androidx.test.platform.app.InstrumentationRegistry
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
class DaoToolsTest {
private lateinit var db: AppDatabase
@Before
fun createDb() {
val context = InstrumentationRegistry.getInstrumentation().context
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build()
}
@After
fun closeDb() {
db.close()
}
@Test
fun testSyncAll() {
val serviceDao = db.serviceDao()
val service = Service(id=0, accountName="test", type=Service.TYPE_CALDAV, principal = null)
service.id = serviceDao.insertOrReplace(service)
val homeSetDao = db.homeSetDao()
val entry1 = HomeSet(id=1, serviceId=service.id, personal=true, url= "https://example.com/1".toHttpUrl())
val entry3 = HomeSet(id=3, serviceId=service.id, personal=true, url= "https://example.com/3".toHttpUrl())
val oldItems = listOf(
entry1,
HomeSet(id=2, serviceId=service.id, personal=true, url= "https://example.com/2".toHttpUrl()),
entry3
)
homeSetDao.insert(oldItems)
val newItems = mutableMapOf<HttpUrl, HomeSet>()
newItems[entry1.url] = entry1
// no id, because identity is given by the url
val updated = HomeSet(id=0, serviceId=service.id, personal=true,
url= "https://example.com/2".toHttpUrl(), displayName="Updated Entry")
newItems[updated.url] = updated
val created = HomeSet(id=4, serviceId=service.id, personal=true, url= "https://example.com/4".toHttpUrl())
newItems[created.url] = created
DaoTools(homeSetDao).syncAll(oldItems, newItems, { it.url })
val afterSync = homeSetDao.getByService(service.id)
assertEquals(afterSync.size, 3)
assertFalse(afterSync.contains(entry3))
assertTrue(afterSync.contains(entry1))
assertTrue(afterSync.contains(updated))
assertTrue(afterSync.contains(created))
}
}

View file

@ -0,0 +1,75 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.db
import androidx.room.Room
import androidx.test.platform.app.InstrumentationRegistry
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
class HomesetDaoTest {
private lateinit var db: AppDatabase
@Before
fun createDb() {
val context = InstrumentationRegistry.getInstrumentation().context
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build()
}
@After
fun closeDb() {
db.close()
}
@Test
fun testInsertOrUpdate() {
// should insert new row or update (upsert) existing row - without changing its key!
val serviceId = createTestService()
val homeSetDao = db.homeSetDao()
val entry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl())
val insertId1 = homeSetDao.insertOrUpdateByUrl(entry1)
assertEquals(1L, insertId1)
assertEquals(entry1.apply { id = 1L }, homeSetDao.getById(1L))
val updatedEntry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl(), displayName="Updated Entry")
val updateId1 = homeSetDao.insertOrUpdateByUrl(updatedEntry1)
assertEquals(1L, updateId1)
assertEquals(updatedEntry1.apply { id = 1L }, homeSetDao.getById(1L))
val entry2 = HomeSet(id=0, serviceId=serviceId, personal=true, url= "https://example.com/2".toHttpUrl())
val insertId2 = homeSetDao.insertOrUpdateByUrl(entry2)
assertEquals(2L, insertId2)
assertEquals(entry2.apply { id = 2L }, homeSetDao.getById(2L))
}
@Test
fun testDelete() {
// should delete row with given primary key (id)
val serviceId = createTestService()
val homesetDao = db.homeSetDao()
val entry1 = HomeSet(id=1, serviceId=serviceId, personal=true, url= "https://example.com/1".toHttpUrl())
val insertId1 = homesetDao.insertOrUpdateByUrl(entry1)
assertEquals(1L, insertId1)
assertEquals(entry1, homesetDao.getById(1L))
homesetDao.delete(entry1)
assertEquals(null, homesetDao.getById(1L))
}
fun createTestService() : Long {
val serviceDao = db.serviceDao()
val service = Service(id=0, accountName="test", type=Service.TYPE_CALDAV, principal = null)
return serviceDao.insertOrReplace(service)
}
}

View file

@ -2,7 +2,7 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.ui.setup
package at.bitfire.davdroid.servicedetection
import android.security.NetworkSecurityPolicy
import androidx.test.filters.SmallTest
@ -13,21 +13,17 @@ import at.bitfire.dav4jvm.property.ResourceType
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.servicedetection.DavResourceFinder
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.servicedetection.DavResourceFinder.Configuration.ServiceInfo
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.ui.setup.LoginModel
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.*
import org.junit.Assert.*
import org.junit.Assume
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.net.URI
import javax.inject.Inject
@ -92,7 +88,7 @@ class DavResourceFinderTest {
var info = ServiceInfo()
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL))
.propfind(0, AddressbookHomeSet.NAME) { response, _ ->
finder.scanCardDavResponse(response, info)
finder.scanResponse(ResourceType.ADDRESSBOOK, response, info)
}
assertEquals(0, info.collections.size)
assertEquals(1, info.homeSets.size)
@ -102,7 +98,7 @@ class DavResourceFinderTest {
info = ServiceInfo()
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK))
.propfind(0, ResourceType.NAME) { response, _ ->
finder.scanCardDavResponse(response, info)
finder.scanResponse(ResourceType.ADDRESSBOOK, response, info)
}
assertEquals(1, info.collections.size)
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), info.collections.keys.first())

View file

@ -0,0 +1,433 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.servicedetection
import android.content.Context
import android.security.NetworkSecurityPolicy
import android.util.Log
import androidx.hilt.work.HiltWorkerFactory
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.Configuration
import androidx.work.WorkManager
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.property.AddressbookHomeSet
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.TestUtils.workScheduledOrRunning
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.Service
import at.bitfire.davdroid.log.Logger
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 okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.*
import org.junit.Assert.*
import java.net.URI
import javax.inject.Inject
@HiltAndroidTest
class RefreshCollectionsWorkerTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
@Inject
lateinit var workerFactory: HiltWorkerFactory
@Before
fun setUp() {
hiltRule.inject()
// The test application is an instance of HiltTestApplication, which doesn't initialize notification channels.
// However, we need notification channels for the ongoing work notifications.
NotificationUtils.createChannels(context)
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.setWorkerFactory(workerFactory)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
}
// Test dependencies
companion object {
private const val PATH_CALDAV = "/caldav"
private const val PATH_CARDDAV = "/carddav"
private const val PATH_CALDAV_AND_CARDDAV = "/both-caldav-carddav"
private const val SUBPATH_PRINCIPAL = "/principal"
private const val SUBPATH_ADDRESSBOOK_HOMESET = "/addressbooks-homeset"
private const val SUBPATH_ADDRESSBOOK_HOMESET_EMPTY = "/addressbooks-homeset-empty"
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
private const val SUBPATH_ADDRESSBOOK_INACCESSIBLE = "/addressbooks/inaccessible-contacts"
}
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var settings: SettingsManager
var mockServer = MockWebServer()
lateinit var client: HttpClient
lateinit var loginModel: LoginModel
@Before
fun mockServerSetup() {
// Start mock web server
mockServer.dispatcher = TestDispatcher()
mockServer.start()
loginModel = LoginModel()
loginModel.baseURI = URI.create("/")
loginModel.credentials = Credentials("mock", "12345")
client = HttpClient.Builder()
.addAuthentication(null, loginModel.credentials!!)
.build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
}
@After
fun cleanUp() {
mockServer.shutdown()
db.close()
}
// Actual tests
@Test
fun testRefreshCollections_enqueuesWorker() {
val service = createTestService(Service.TYPE_CALDAV)!!
val workerName = RefreshCollectionsWorker.refreshCollections(context, service.id)
assertTrue(workScheduledOrRunning(context, workerName))
}
@Test
fun testOnStopped_stopsRefreshThread() {
val service = createTestService(Service.TYPE_CALDAV)!!
val workerName = RefreshCollectionsWorker.refreshCollections(context, service.id)
WorkManager.getInstance(context).cancelUniqueWork(workerName)
assertFalse(workScheduledOrRunning(context, workerName))
// here we should test whether stopping the work really interrupts the refresh thread
}
@Test
fun testQueryHomesets() {
val service = createTestService(Service.TYPE_CARDDAV)!!
val baseUrl = mockServer.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)
// Query home sets
DavResource(client.okHttpClient, baseUrl).propfind(0, AddressbookHomeSet.NAME) { response, _ ->
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.queryHomeSets(baseUrl)
}
// Check home sets have been saved to database
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET/"), db.homeSetDao().getByService(service.id).first().url)
assertEquals(1, db.homeSetDao().getByService(service.id).size)
}
// refreshHomesetsAndTheirCollections
@Test
fun refreshHomesetsAndTheirCollections_addsNewCollection() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// save homeset in DB
val homesetId = db.homeSetDao().insert(
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
)
// Refresh
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshHomesetsAndTheirCollections()
// Check the collection defined in homeset is now in the database
assertEquals(
Collection(
1,
service.id,
homesetId,
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() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// save "old" collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description"
)
)
// Refresh
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshHomesetsAndTheirCollections()
// Check the collection got updated
assertEquals(
Collection(
collectionId,
service.id,
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() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// save "old" collection in DB - with set flags
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description",
forceReadOnly = true,
sync = true
)
)
// Refresh
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshHomesetsAndTheirCollections()
// Check the collection got updated
assertEquals(
Collection(
collectionId,
service.id,
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() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// 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,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
)
// Refresh - should mark collection as homeless, because serverside homeset is empty
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshHomesetsAndTheirCollections()
// Check the collection, is now marked as homeless
assertEquals(null, db.collectionDao().get(collectionId)!!.homeSetId)
}
// refreshHomelessCollections
@Test
fun refreshHomelessCollections_updatesExistingCollection() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// place homeless collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
)
)
// Refresh
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshHomelessCollections()
// Check the collection got updated - with display name and description
assertEquals(
Collection(
collectionId,
service.id,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description"
),
db.collectionDao().get(collectionId)
)
}
@Test
fun refreshHomelessCollections_deletesInaccessibleCollections() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// place homeless collection in DB - it is also inaccessible
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_INACCESSIBLE")
)
)
// Refresh - should delete collection
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshHomelessCollections()
// Check the collection got deleted
assertEquals(null, db.collectionDao().get(collectionId))
}
// Test helpers and dependencies
fun createTestService(serviceType: String) : Service? {
val service = Service(id=0, accountName="test", type=serviceType, principal = null)
val serviceId = db.serviceDao().insertOrReplace(service)
return db.serviceDao().get(serviceId)
}
class TestDispatcher: Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val path = request.path!!
if (request.method.equals("PROPFIND", true)) {
val properties = when (path) {
PATH_CALDAV,
PATH_CARDDAV ->
"<current-user-principal>" +
" <href>$path${SUBPATH_PRINCIPAL}</href>" +
"</current-user-principal>"
PATH_CARDDAV + SUBPATH_PRINCIPAL ->
"<CARD:addressbook-home-set>" +
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET}</href>" +
"</CARD:addressbook-home-set>"
PATH_CARDDAV + SUBPATH_ADDRESSBOOK + "/",
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET ->
"<resourcetype>" +
" <collection/>" +
" <CARD:addressbook/>" +
"</resourcetype>" +
"<displayname>My Contacts</displayname>" +
"<CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>"
PATH_CALDAV + SUBPATH_PRINCIPAL ->
"<CAL:calendar-user-address-set>" +
" <href>urn:unknown-entry</href>" +
" <href>mailto:email1@example.com</href>" +
" <href>mailto:email2@example.com</href>" +
"</CAL:calendar-user-address-set>"
else -> ""
}
var responseBody: String = ""
var responseCode: Int = 207
when (path) {
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET ->
responseBody =
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
"<response>" +
" <href>${PATH_CARDDAV + SUBPATH_ADDRESSBOOK}</href>" +
" <propstat><prop>" +
properties +
" </prop></propstat>" +
" <status>HTTP/1.1 200 OK</status>" +
"</response>" +
"</multistatus>"
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_INACCESSIBLE ->
responseCode = 404
else ->
responseBody =
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
"<response>" +
" <href>$path</href>" +
" <propstat><prop>"+
properties +
" </prop></propstat>" +
"</response>" +
"</multistatus>"
}
Logger.log.info("Queried: $path")
Logger.log.info("Response: $responseBody")
return MockResponse()
.setResponseCode(responseCode)
.setBody(responseBody)
}
return MockResponse().setResponseCode(404)
}
}
}

View file

@ -11,19 +11,14 @@ import android.content.Context
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.Configuration
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.impl.utils.SynchronousExecutor
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.davdroid.R
import at.bitfire.davdroid.TestUtils.workScheduledOrRunning
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.ui.NotificationUtils
import com.google.common.util.concurrent.ListenableFuture
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.After
@ -31,8 +26,6 @@ import org.junit.Assert.*
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.TimeUnit
@HiltAndroidTest
class SyncWorkerTest {
@ -74,7 +67,7 @@ class SyncWorkerTest {
fun testRequestSync_enqueuesWorker() {
SyncWorker.requestSync(context, account, CalendarContract.AUTHORITY)
val workerName = SyncWorker.workerName(account, CalendarContract.AUTHORITY)
assertTrue(workScheduledOrRunning(workerName))
assertTrue(workScheduledOrRunning(context, workerName))
}
@Test
@ -82,26 +75,9 @@ class SyncWorkerTest {
SyncWorker.requestSync(context, account, CalendarContract.AUTHORITY)
SyncWorker.stopSync(context, account, CalendarContract.AUTHORITY)
val workerName = SyncWorker.workerName(account, CalendarContract.AUTHORITY)
assertFalse(workScheduledOrRunning(workerName))
assertFalse(workScheduledOrRunning(context, workerName))
// here we could test whether stopping the work really interrupts the sync thread
}
private fun workScheduledOrRunning(workerName: String): Boolean {
val future: ListenableFuture<List<WorkInfo>> = WorkManager.getInstance(context).getWorkInfosForUniqueWork(workerName)
val workInfoList: List<WorkInfo>
try {
workInfoList = future.get()
} catch (e: Exception) {
Logger.log.severe("Failed to retrieve work info list for worker $workerName", )
return false
}
for (workInfo in workInfoList) {
val state = workInfo.state
if (state == WorkInfo.State.RUNNING || state == WorkInfo.State.ENQUEUED)
return true
}
return false
}
}

View file

@ -60,12 +60,6 @@
<service android:name=".ForegroundService"/>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove">
</provider>
<service android:name=".ForegroundService"/>
<activity android:name=".ui.intro.IntroActivity" android:theme="@style/AppTheme.NoActionBar" />

View file

@ -26,12 +26,28 @@ import org.apache.commons.lang3.StringUtils
)
data class Collection(
@PrimaryKey(autoGenerate = true)
override var id: Long = 0,
var id: Long = 0,
/**
* Service, which this collection belongs to. Services are unique, so a [Collection] is uniquely
* identifiable via its [serviceId] and [url].
*/
var serviceId: Long = 0,
/**
* A home set this collection belongs to. Multiple homesets are not supported.
* If *null* the collection is considered homeless.
*/
var homeSetId: Long? = null,
/**
* Type of service. CalDAV or CardDAV
*/
var type: String,
/**
* Address where this collection lives
*/
var url: HttpUrl,
var privWriteContent: Boolean = true,
@ -63,7 +79,7 @@ data class Collection(
/** whether this collection has been selected for synchronization */
var sync: Boolean = false
): IdEntity {
) {
@Ignore
var refHomeSet: HomeSet? = null
@ -161,12 +177,6 @@ data class Collection(
}
// non-persistent properties
@Ignore
var confirmed: Boolean = false
// calculated properties
fun title() = displayName ?: DavUtils.lastSegmentOfUrl(url)
fun readOnly() = forceReadOnly || !privWriteContent

View file

@ -6,13 +6,10 @@ package at.bitfire.davdroid.db
import androidx.lifecycle.LiveData
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.*
@Dao
interface CollectionDao: SyncableDao<Collection> {
interface CollectionDao {
@Query("SELECT DISTINCT color FROM collection WHERE serviceId=:id")
fun colorsByServiceLive(id: Long): LiveData<List<Int>>
@ -26,6 +23,9 @@ interface CollectionDao: SyncableDao<Collection> {
@Query("SELECT * FROM collection WHERE serviceId=:serviceId")
fun getByService(serviceId: Long): List<Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND homeSetId IS :homeSetId")
fun getByServiceAndHomeset(serviceId: Long, homeSetId: Long?): List<Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type ORDER BY displayName, url")
fun getByServiceAndType(serviceId: Long, type: String): List<Collection>
@ -44,9 +44,13 @@ interface CollectionDao: SyncableDao<Collection> {
@Query("SELECT collection.* FROM collection, homeset WHERE collection.serviceId=:serviceId AND type=:type AND homeSetId=homeset.id AND homeset.personal ORDER BY collection.displayName, collection.url")
fun pagePersonalByServiceAndType(serviceId: Long, type: String): PagingSource<Int, Collection>
@Deprecated("Use getByServiceAndUrl instead")
@Query("SELECT * FROM collection WHERE url=:url")
fun getByUrl(url: String): Collection?
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND url=:url")
fun getByServiceAndUrl(serviceId: Long, url: String): Collection?
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' AND supportsVEVENT AND sync ORDER BY displayName, url")
fun getSyncCalendars(serviceId: Long): List<Collection>
@ -56,10 +60,29 @@ interface CollectionDao: SyncableDao<Collection> {
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' AND supportsVTODO AND sync ORDER BY displayName, url")
fun getSyncTaskLists(serviceId: Long): List<Collection>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrReplace(collection: Collection)
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insert(collection: Collection): Long
@Insert
fun insert(collection: Collection)
@Update
fun update(collection: Collection)
/**
* Tries to insert new row, but updates existing row if already present.
* This method preserves the primary key, as opposed to using "@Insert(onConflict = OnConflictStrategy.REPLACE)"
* which will create a new row with incremented ID and thus breaks entity relationships!
*
* @return ID of the row, that has been inserted or updated. -1 If the insert fails due to other reasons.
*/
@Transaction
fun insertOrUpdateByUrl(collection: Collection): Long = getByServiceAndUrl(
collection.serviceId,
collection.url.toString()
)?.let { localCollection ->
update(collection.copy(id = localCollection.id))
localCollection.id
} ?: insert(collection)
@Delete
fun delete(collection: Collection)
}

View file

@ -7,6 +7,7 @@ package at.bitfire.davdroid.db
import at.bitfire.davdroid.log.Logger
import java.util.logging.Level
@Deprecated("Use direct DB access instead")
class DaoTools<T: IdEntity>(dao: SyncableDao<T>): SyncableDao<T> by dao {
/**

View file

@ -21,7 +21,7 @@ import okhttp3.HttpUrl
)
data class HomeSet(
@PrimaryKey(autoGenerate = true)
override var id: Long,
var id: Long,
var serviceId: Long,
@ -35,4 +35,4 @@ data class HomeSet(
var privBind: Boolean = true,
var displayName: String? = null
): IdEntity
)

View file

@ -5,13 +5,16 @@
package at.bitfire.davdroid.db
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.*
@Dao
interface HomeSetDao: SyncableDao<HomeSet> {
interface HomeSetDao {
@Query("SELECT * FROM homeset WHERE id=:homesetId")
fun getById(homesetId: Long): HomeSet
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND url=:url")
fun getByUrl(serviceId: Long, url: String): HomeSet?
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId")
fun getByService(serviceId: Long): List<HomeSet>
@ -22,7 +25,27 @@ interface HomeSetDao: SyncableDao<HomeSet> {
@Query("SELECT COUNT(*) FROM homeset WHERE serviceId=:serviceId AND privBind")
fun hasBindableByServiceLive(serviceId: Long): LiveData<Boolean>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrReplace(homeSet: HomeSet): Long
@Insert
fun insert(homeSet: HomeSet): Long
@Update
fun update(homeset: HomeSet)
/**
* Tries to insert new row, but updates existing row if already present.
* This method preserves the primary key, as opposed to using "@Insert(onConflict = OnConflictStrategy.REPLACE)"
* which will create a new row with incremented ID and thus breaks entity relationships!
*
* @return ID of the row, that has been inserted or updated. -1 If the insert fails due to other reasons.
*/
@Transaction
fun insertOrUpdateByUrl(homeset: HomeSet): Long =
getByUrl(homeset.serviceId, homeset.url.toString())?.let { existingHomeset ->
update(homeset.copy(id = existingHomeset.id))
existingHomeset.id
} ?: insert(homeset)
@Delete
fun delete(homeset: HomeSet)
}

View file

@ -8,6 +8,7 @@ package at.bitfire.davdroid.db
* A model with a primary ID. Must be overriden with `@PrimaryKey(autoGenerate = true)`.
* Required for [DaoTools] so that ID fields of all model classes have the same schema.
*/
@Deprecated("Use direct DB access instead")
interface IdEntity {
var id: Long
}

View file

@ -9,6 +9,11 @@ import androidx.room.Index
import androidx.room.PrimaryKey
import okhttp3.HttpUrl
/**
* A service entity.
*
* Services represent accounts and are unique. They are of type CardDAV or CalDAV and may have an associated principal.
*/
@Entity(tableName = "service",
indices = [
// only one service per type and account
@ -16,13 +21,13 @@ import okhttp3.HttpUrl
])
data class Service(
@PrimaryKey(autoGenerate = true)
override var id: Long,
var id: Long,
var accountName: String,
var type: String,
var principal: HttpUrl?
): IdEntity {
) {
companion object {
const val TYPE_CALDAV = "caldav"

View file

@ -9,6 +9,7 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Update
@Deprecated("Use direct DB access instead")
interface SyncableDao<T: IdEntity> {
@Insert(onConflict = OnConflictStrategy.REPLACE)

View file

@ -11,7 +11,7 @@ import okhttp3.HttpUrl
@Entity(tableName = "webdav_mount")
data class WebDavMount(
@PrimaryKey(autoGenerate = true)
override var id: Long = 0,
var id: Long = 0,
/** display name of the WebDAV mount */
var name: String,
@ -21,4 +21,4 @@ data class WebDavMount(
// credentials are stored using CredentialsStore
): IdEntity
)

View file

@ -5,17 +5,18 @@ package at.bitfire.davdroid.servicedetection
import android.content.Context
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.exception.UnauthorizedException
import at.bitfire.dav4jvm.property.*
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.log.StringHandler
import at.bitfire.davdroid.ui.setup.LoginModel
import at.bitfire.davdroid.util.DavUtils
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.apache.commons.lang3.builder.ReflectionToStringBuilder
@ -30,6 +31,14 @@ import java.util.*
import java.util.logging.Level
import java.util.logging.Logger
/**
* Does initial resource detection (straight after app install). Called after user has supplied url in
* app setup process [at.bitfire.davdroid.ui.setup.DetectConfigurationFragment].
* It uses the (user given) base URL to find
* - services (CalDAV and/or CardDAV),
* - principal,
* - homeset/collections (multistatus responses are handled through dav4jvm).
*/
class DavResourceFinder(
val context: Context,
private val loginModel: LoginModel
@ -106,41 +115,45 @@ class DavResourceFinder(
// domain for service discovery
var discoveryFQDN: String? = null
// put discovered information here
// discovered information goes into this config
val config = Configuration.ServiceInfo()
// Start discovering
log.info("Finding initial ${service.wellKnownName} service configuration")
when (baseURI.scheme.lowercase()) {
"http", "https" ->
baseURI.toHttpUrlOrNull()?.let { baseURL ->
// remember domain for service discovery
if (baseURL.scheme.equals("https", true))
// service discovery will only be tried for https URLs, because only secure service discovery is implemented
discoveryFQDN = baseURL.host
if (baseURI.scheme.equals("http", true) || baseURI.scheme.equals("https", true)) {
baseURI.toHttpUrlOrNull()?.let { baseURL ->
// remember domain for service discovery
// try service discovery only for https:// URLs because only secure service discovery is implemented
if (baseURL.scheme.equals("https", true))
discoveryFQDN = baseURL.host
// Actual discovery process
checkBaseURL(baseURL, service, config)
checkUserGivenURL(baseURL, service, config)
if (config.principal == null)
try {
config.principal = getCurrentUserPrincipal(baseURL.resolve("/.well-known/" + service.wellKnownName)!!, service)
} catch(e: Exception) {
log.log(Level.FINE, "Well-known URL detection failed", e)
processException(e)
}
// If principal was not found already, try well known URI
if (config.principal == null)
try {
config.principal = getCurrentUserPrincipal(baseURL.resolve("/.well-known/" + service.wellKnownName)!!, service)
} catch(e: Exception) {
log.log(Level.FINE, "Well-known URL detection failed", e)
processException(e)
}
}
"mailto" -> {
val mailbox = baseURI.schemeSpecificPart
val posAt = mailbox.lastIndexOf("@")
if (posAt != -1)
discoveryFQDN = mailbox.substring(posAt + 1)
}
} else if (baseURI.scheme.equals("mailto", true)) {
val mailbox = baseURI.schemeSpecificPart
val posAt = mailbox.lastIndexOf("@")
if (posAt != -1)
discoveryFQDN = mailbox.substring(posAt + 1)
}
// Step 2: If user-given URL didn't reveal a principal, search for it: SERVICE DISCOVERY
// Second try: If user-given URL didn't reveal a principal, search for it (SERVICE DISCOVERY)
if (config.principal == null)
discoveryFQDN?.let {
log.info("No principal found at user-given URL, trying to discover")
discoveryFQDN?.let { fqdn ->
log.info("No principal found at user-given URL, trying to discover for domain $fqdn")
try {
config.principal = discoverPrincipalUrl(it, service)
config.principal = discoverPrincipalUrl(fqdn, service)
} catch(e: Exception) {
log.log(Level.FINE, "$service service discovery failed", e)
processException(e)
@ -149,8 +162,8 @@ class DavResourceFinder(
// detect email address
if (service == Service.CALDAV)
config.principal?.let {
config.emails.addAll(queryEmailAddress(it))
config.principal?.let { principal ->
config.emails.addAll(queryEmailAddress(principal))
}
// return config or null if config doesn't contain useful information
@ -161,28 +174,40 @@ class DavResourceFinder(
null
}
private fun checkUserGivenURL(baseURL: HttpUrl, service: Service, config: Configuration.ServiceInfo) {
/**
* Entry point of the actual discovery process.
*
* Queries the user-given URL (= base URL) to detect whether it contains a current-user-principal
* or whether it is a homeset or collection.
*
* @param baseURL base URL provided by the user
* @param service service to detect configuration for
* @param config found configuration will be written to this object
*/
private fun checkBaseURL(baseURL: HttpUrl, service: Service, config: Configuration.ServiceInfo) {
log.info("Checking user-given URL: $baseURL")
val davBase = DavResource(httpClient.okHttpClient, baseURL, log)
val davBaseURL = DavResource(httpClient.okHttpClient, baseURL, log)
try {
when (service) {
Service.CARDDAV -> {
davBase.propfind(0,
ResourceType.NAME, DisplayName.NAME, AddressbookDescription.NAME,
AddressbookHomeSet.NAME,
CurrentUserPrincipal.NAME
davBaseURL.propfind(
0,
ResourceType.NAME, DisplayName.NAME, AddressbookDescription.NAME,
AddressbookHomeSet.NAME,
CurrentUserPrincipal.NAME
) { response, _ ->
scanCardDavResponse(response, config)
scanResponse(ResourceType.ADDRESSBOOK, response, config)
}
}
Service.CALDAV -> {
davBase.propfind(0,
ResourceType.NAME, DisplayName.NAME, CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME, CurrentUserPrivilegeSet.NAME, SupportedCalendarComponentSet.NAME,
CalendarHomeSet.NAME,
CurrentUserPrincipal.NAME
davBaseURL.propfind(
0,
ResourceType.NAME, DisplayName.NAME, CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME, CurrentUserPrivilegeSet.NAME, SupportedCalendarComponentSet.NAME,
CalendarHomeSet.NAME,
CurrentUserPrincipal.NAME
) { response, _ ->
scanCalDavResponse(response, config)
scanResponse(ResourceType.CALENDAR, response, config)
}
}
}
@ -220,97 +245,80 @@ class DavResourceFinder(
}
/**
* If [dav] references an address book, an address book home set, and/or a princiapl,
* it will added to, config.collections, config.homesets and/or config.principal.
* URLs will be stored with trailing "/".
* Depending on [resourceType] (CalDAV or CardDAV), this method checks whether [davResponse] references
* - an address book or calendar (actual resource), and/or
* - an "address book home set" or a "calendar home set", and/or
* - whether it's a principal.
*
* @param dav response whose properties are evaluated
* @param config structure where the results are stored into
* Respectively, this method will add the response to [config.collections], [config.homesets] and/or [config.principal].
* Collection URLs will be stored with trailing "/".
*
* @param resourceType type of service to search for in the response
* @param davResponse response whose properties are evaluated
* @param config structure storing the references
*/
fun scanCardDavResponse(dav: Response, config: Configuration.ServiceInfo) {
fun scanResponse(resourceType: Property.Name, davResponse: Response, config: Configuration.ServiceInfo) {
var principal: HttpUrl? = null
// check for current-user-principal
dav[CurrentUserPrincipal::class.java]?.href?.let {
principal = dav.requestedUrl.resolve(it)
}
// Is it an address book and/or principal?
dav[ResourceType::class.java]?.let {
if (it.types.contains(ResourceType.ADDRESSBOOK)) {
val info = Collection.fromDavResponse(dav)!!
log.info("Found address book at ${info.url}")
config.collections[info.url] = info
// Type mapping
val homeSetClass: Class<out HrefListProperty>
val serviceType: Service
when (resourceType) {
ResourceType.ADDRESSBOOK -> {
homeSetClass = AddressbookHomeSet::class.java
serviceType = Service.CARDDAV
}
if (it.types.contains(ResourceType.PRINCIPAL))
principal = dav.href
ResourceType.CALENDAR -> {
homeSetClass = CalendarHomeSet::class.java
serviceType = Service.CALDAV
}
else -> throw IllegalArgumentException()
}
// Is it an addressbook-home-set?
dav[AddressbookHomeSet::class.java]?.let { homeSet ->
// check for current-user-principal
davResponse[CurrentUserPrincipal::class.java]?.href?.let { currentUserPrincipal ->
principal = davResponse.requestedUrl.resolve(currentUserPrincipal)
}
davResponse[ResourceType::class.java]?.let {
// Is it a calendar or an address book, ...
if (it.types.contains(resourceType))
Collection.fromDavResponse(davResponse)?.let { info ->
log.info("Found resource of type $resourceType at ${info.url}")
config.collections[info.url] = info
}
// ... and/or a principal?
if (it.types.contains(ResourceType.PRINCIPAL))
principal = davResponse.href
}
// Is it an addressbook-home-set or calendar-home-set?
davResponse[homeSetClass]?.let { homeSet ->
for (href in homeSet.hrefs) {
dav.requestedUrl.resolve(href)?.let {
davResponse.requestedUrl.resolve(href)?.let {
val location = UrlUtils.withTrailingSlash(it)
log.info("Found address book home-set at $location")
log.info("Found home-set of type $resourceType at $location")
config.homeSets += location
}
}
}
// Is there a principal too?
principal?.let {
if (providesService(it, Service.CARDDAV))
if (providesService(it, serviceType))
config.principal = principal
}
}
/**
* If [dav] references an address book, an address book home set, and/or a princiapl,
* it will added to, config.collections, config.homesets and/or config.principal.
* URLs will be stored with trailing "/".
* Sends an OPTIONS request to determine whether a URL provides a given service.
*
* @param dav response whose properties are evaluated
* @param config structure where the results are stored into
* @param url URL to check; often a principal URL
* @param service service to check for
*
* @return whether the URL provides the given service
*/
private fun scanCalDavResponse(dav: Response, config: Configuration.ServiceInfo) {
var principal: HttpUrl? = null
// check for current-user-principal
dav[CurrentUserPrincipal::class.java]?.href?.let {
principal = dav.requestedUrl.resolve(it)
}
// Is it a calendar and/or principal?
dav[ResourceType::class.java]?.let {
if (it.types.contains(ResourceType.CALENDAR)) {
val info = Collection.fromDavResponse(dav)!!
log.info("Found calendar at ${info.url}")
config.collections[info.url] = info
}
if (it.types.contains(ResourceType.PRINCIPAL))
principal = dav.href
}
// Is it an calendar-home-set?
dav[CalendarHomeSet::class.java]?.let { homeSet ->
for (href in homeSet.hrefs) {
dav.requestedUrl.resolve(href)?.let {
val location = UrlUtils.withTrailingSlash(it)
log.info("Found calendar home-set at $location")
config.homeSets += location
}
}
}
principal?.let {
if (providesService(it, Service.CALDAV))
config.principal = principal
}
}
@Throws(IOException::class)
fun providesService(url: HttpUrl, service: Service): Boolean {
var provided = false
try {
@ -331,11 +339,11 @@ class DavResourceFinder(
/**
* Try to find the principal URL by performing service discovery on a given domain name.
* Only secure services (caldavs, carddavs) will be discovered!
*
* @param domain domain name, e.g. "icloud.com"
* @param service service to discover (CALDAV or CARDDAV)
* @return principal URL, or null if none found
*/
@Throws(IOException::class, HttpException::class, DavException::class)
fun discoverPrincipalUrl(domain: String, service: Service): HttpUrl? {
val scheme: String
val fqdn: String
@ -368,9 +376,9 @@ class DavResourceFinder(
DavUtils.prepareLookup(context, txtLookup)
paths.addAll(DavUtils.pathsFromTXTRecords(txtLookup.run()))
// if there's TXT record and if it it's wrong, try well-known
// in case there's a TXT record, but it's wrong, try well-known
paths.add("/.well-known/" + service.wellKnownName)
// if this fails, too, try "/"
// if this fails too, try "/"
paths.add("/")
for (path in paths)
@ -399,7 +407,6 @@ class DavResourceFinder(
* @param service required service (may be null, in which case no service check is done)
* @return current-user-principal URL that provides required service, or null if none
*/
@Throws(IOException::class, HttpException::class, DavException::class)
fun getCurrentUserPrincipal(url: HttpUrl, service: Service?): HttpUrl? {
var principal: HttpUrl? = null
DavResource(httpClient.okHttpClient, url, log).propfind(0, CurrentUserPrincipal.NAME) { response, _ ->
@ -419,7 +426,7 @@ class DavResourceFinder(
}
/**
* Processes a thrown exception likes this:
* Processes a thrown exception like this:
*
* - If the Exception is an [UnauthorizedException] (HTTP 401), [encountered401] is set to *true*.
* - Re-throws the exception if it signals that the current thread was interrupted to stop the current operation.

View file

@ -13,16 +13,16 @@ import androidx.core.app.NotificationManagerCompat
import androidx.hilt.work.HiltWorker
import androidx.lifecycle.Transformations
import androidx.work.*
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.dav4jvm.*
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.*
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.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Settings
@ -35,40 +35,67 @@ import dagger.assisted.AssistedInject
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import java.util.logging.Level
import javax.inject.Inject
import kotlin.collections.*
/**
* Refreshes list of home sets and their respective collections of a service type (CardDAV or CalDAV).
* Called from UI, when user wants to refresh all collections of a service ([at.bitfire.davdroid.ui.account.CollectionsFragment]).
*
* Input data:
*
* - [ARG_SERVICE_ID]: service ID
*
* It queries all existing homesets and/or collections and then:
* - updates resources with found properties (overwrites without comparing)
* - adds resources if new ones are detected
* - removes resources if not found 40x (delete locally)
*
* @throws IllegalArgumentException when there's no service with the given service ID
*/
@HiltWorker
class RefreshCollectionsWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters
@Assisted workerParams: WorkerParameters,
var db: AppDatabase,
var settings: SettingsManager
): Worker(appContext, workerParams) {
companion object {
const val ARG_SERVICE_ID = "serviceId"
const val REFRESH_COLLECTION_WORKER_TAG = "refreshCollectionWorker"
const val REFRESH_COLLECTIONS_WORKER_TAG = "refreshCollectionsWorker"
// Collection properties to ask for in a propfind request to the Cal- or CardDAV server
val DAV_COLLECTION_PROPERTIES = arrayOf(
ResourceType.NAME,
CurrentUserPrivilegeSet.NAME,
DisplayName.NAME,
Owner.NAME,
AddressbookDescription.NAME, SupportedAddressData.NAME,
CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME,
Source.NAME
ResourceType.NAME,
CurrentUserPrivilegeSet.NAME,
DisplayName.NAME,
Owner.NAME,
AddressbookDescription.NAME, SupportedAddressData.NAME,
CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME,
Source.NAME
)
fun workerName(serviceId: Long): String {
return "$REFRESH_COLLECTION_WORKER_TAG-$serviceId"
}
/**
* 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"
/**
* Requests immediate refresh of a given service
* Requests immediate refresh of a given service. If not running already. this will enqueue
* a [RefreshCollectionsWorker].
*
* @param serviceId serviceId which is to be refreshed
* @return workerName name of the worker started
*
* @throws IllegalArgumentException when there's no service with this ID
*/
fun refreshCollections(context: Context, serviceId: Long) {
fun refreshCollections(context: Context, serviceId: Long): String {
if (serviceId == -1L)
throw IllegalArgumentException("Service with ID \"$serviceId\" does not exist")
val arguments = Data.Builder()
.putLong(ARG_SERVICE_ID, serviceId)
.build()
@ -79,174 +106,33 @@ class RefreshCollectionsWorker @AssistedInject constructor(
WorkManager.getInstance(context).enqueueUniqueWork(
workerName(serviceId),
ExistingWorkPolicy.KEEP, // if refresh is already running, just continue
ExistingWorkPolicy.KEEP, // if refresh is already running, just continue that one
workRequest
)
return workerName(serviceId)
}
/**
* Will tell whether a refresh worker with given service id and state exists
*
* @param serviceId the service which the worker(s) belong to
* @param workState state of worker to match
* @return boolean true if worker with matching state was found
* @param workerName name of worker to find
* @param workState state of worker to match
* @return boolean true if worker with matching state was found
*/
fun isWorkerInState(context: Context, serviceId: Long, workState: WorkInfo.State) = Transformations.map(
WorkManager.getInstance(context).getWorkInfosForUniqueWorkLiveData(workerName(serviceId))
fun isWorkerInState(context: Context, workerName: String, workState: WorkInfo.State) = Transformations.map(
WorkManager.getInstance(context).getWorkInfosForUniqueWorkLiveData(workerName)
) { workInfoList -> workInfoList.any { workInfo -> workInfo.state == workState } }
}
@Inject lateinit var db: AppDatabase
@Inject lateinit var settings: SettingsManager
val serviceId: Long = inputData.getLong(ARG_SERVICE_ID, -1)
val service = db.serviceDao().get(serviceId) ?: throw IllegalArgumentException("Service #$serviceId not found")
val account = Account(service.accountName, applicationContext.getString(R.string.account_type))
override fun getForegroundInfoAsync(): ListenableFuture<ForegroundInfo> =
CallbackToFutureAdapter.getFuture { completer ->
val notification = NotificationUtils.newBuilder(applicationContext, NotificationUtils.CHANNEL_STATUS)
.setSmallIcon(R.drawable.ic_foreground_notify)
.setContentTitle(applicationContext.getString(R.string.foreground_service_notify_title))
.setContentText(applicationContext.getString(R.string.foreground_service_notify_text))
.setStyle(NotificationCompat.BigTextStyle())
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
completer.set(ForegroundInfo(NotificationUtils.NOTIFY_SYNC_EXPEDITED, notification))
}
/** thread which runs the actual refresh code (can be interrupted to stop refreshing) */
var refreshThread: Thread? = null
override fun doWork(): Result {
val serviceId = inputData.getLong(ARG_SERVICE_ID, -1)
if (serviceId == -1L)
return Result.failure()
val syncAllCollections = settings.getBoolean(Settings.SYNC_ALL_COLLECTIONS)
val homeSetDao = db.homeSetDao()
val collectionDao = db.collectionDao()
val service = db.serviceDao().get(serviceId) ?: throw IllegalArgumentException("Service not found")
val account = Account(service.accountName, applicationContext.getString(R.string.account_type))
val homeSets = homeSetDao.getByService(serviceId).associateBy { it.url }.toMutableMap()
val collections = collectionDao.getByService(serviceId).associateBy { it.url }.toMutableMap()
/**
* Checks if the given URL defines home sets and adds them to the home set list.
*
* @param personal Whether this is the "outer" call of the recursion.
*
* *true* = found home sets belong to the current-user-principal; recurse if
* calendar proxies or group memberships are found
*
* *false* = found home sets don't directly belong to the current-user-principal; don't recurse
*
* @throws java.io.IOException
* @throws HttpException
* @throws at.bitfire.dav4jvm.exception.DavException
*/
fun queryHomeSets(client: OkHttpClient, url: HttpUrl, personal: Boolean = true) {
val related = mutableSetOf<HttpUrl>()
fun findRelated(root: HttpUrl, dav: Response) {
// refresh home sets: calendar-proxy-read/write-for
dav[CalendarProxyReadFor::class.java]?.let {
for (href in it.hrefs) {
Logger.log.fine("Principal is a read-only proxy for $href, checking for home sets")
root.resolve(href)?.let { proxyReadFor ->
related += proxyReadFor
}
}
}
dav[CalendarProxyWriteFor::class.java]?.let {
for (href in it.hrefs) {
Logger.log.fine("Principal is a read/write proxy for $href, checking for home sets")
root.resolve(href)?.let { proxyWriteFor ->
related += proxyWriteFor
}
}
}
// refresh home sets: direct group memberships
dav[GroupMembership::class.java]?.let {
for (href in it.hrefs) {
Logger.log.fine("Principal is member of group $href, checking for home sets")
root.resolve(href)?.let { groupMembership ->
related += groupMembership
}
}
}
}
val dav = DavResource(client, url)
when (service.type) {
Service.TYPE_CARDDAV ->
try {
dav.propfind(0, DisplayName.NAME, AddressbookHomeSet.NAME, GroupMembership.NAME) { response, _ ->
response[AddressbookHomeSet::class.java]?.let { homeSet ->
for (href in homeSet.hrefs)
dav.location.resolve(href)?.let {
val foundUrl = UrlUtils.withTrailingSlash(it)
homeSets[foundUrl] = HomeSet(0, service.id, personal, foundUrl)
}
}
if (personal)
findRelated(dav.location, response)
}
} catch (e: HttpException) {
if (e.code/100 == 4)
Logger.log.log(Level.INFO, "Ignoring Client Error 4xx while looking for addressbook home sets", e)
else
throw e
}
Service.TYPE_CALDAV -> {
try {
dav.propfind(0, DisplayName.NAME, CalendarHomeSet.NAME, CalendarProxyReadFor.NAME, CalendarProxyWriteFor.NAME, GroupMembership.NAME) { response, _ ->
response[CalendarHomeSet::class.java]?.let { homeSet ->
for (href in homeSet.hrefs)
dav.location.resolve(href)?.let {
val foundUrl = UrlUtils.withTrailingSlash(it)
homeSets[foundUrl] = HomeSet(0, service.id, personal, foundUrl)
}
}
if (personal)
findRelated(dav.location, response)
}
} catch (e: HttpException) {
if (e.code/100 == 4)
Logger.log.log(Level.INFO, "Ignoring Client Error 4xx while looking for calendar home sets", e)
else
throw e
}
}
}
// query related homesets (those that do not belong to the current-user-principal)
for (resource in related)
queryHomeSets(client, resource, false)
}
fun saveHomesets() {
// syncAll sets the ID of the new homeset to the ID of the old one when the URLs are matching
DaoTools(homeSetDao).syncAll(
homeSetDao.getByService(serviceId),
homeSets,
{ it.url })
}
fun saveCollections() {
// syncAll sets the ID of the new collection to the ID of the old one when the URLs are matching
DaoTools(collectionDao).syncAll(
collectionDao.getByService(serviceId),
collections, { it.url }) { new, old ->
// use old settings of "force read only" and "sync", regardless of detection results
new.forceReadOnly = old.forceReadOnly
new.sync = old.sync
}
}
try {
Logger.log.info("Refreshing ${service.type} collections of service #$service")
@ -255,106 +141,26 @@ class RefreshCollectionsWorker @AssistedInject constructor(
.cancel(serviceId.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS)
// create authenticating OkHttpClient (credentials taken from account settings)
refreshThread = Thread.currentThread()
HttpClient.Builder(applicationContext, AccountSettings(applicationContext, account))
.setForeground(true)
.build().use { client ->
val httpClient = client.okHttpClient
val refresher = Refresher(db, service, settings, httpClient)
// refresh home set list (from principal)
// refresh home set list (from principal url) and save them
service.principal?.let { principalUrl ->
Logger.log.fine("Querying principal $principalUrl for home sets")
queryHomeSets(httpClient, principalUrl)
refresher.queryHomeSets(principalUrl)
}
// now refresh homesets and their member collections
val itHomeSets = homeSets.iterator()
while (itHomeSets.hasNext()) {
val (homeSetUrl, homeSet) = itHomeSets.next()
Logger.log.fine("Listing home set $homeSetUrl")
// now refresh home sets and their member collections
refresher.refreshHomesetsAndTheirCollections()
try {
DavResource(httpClient, homeSetUrl).propfind(1, *DAV_COLLECTION_PROPERTIES) { response, relation ->
if (!response.isSuccess())
return@propfind
if (relation == Response.HrefRelation.SELF) {
// this response is about the homeset itself
homeSet.displayName = response[DisplayName::class.java]?.displayName
homeSet.privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind ?: true
}
// in any case, check whether the response is about a useable collection
val info = Collection.fromDavResponse(response) ?: return@propfind
info.serviceId = serviceId
info.refHomeSet = homeSet
info.confirmed = true
// whether new collections are selected for synchronization by default (controlled by managed setting)
info.sync = syncAllCollections
info.owner = response[Owner::class.java]?.href?.let { response.href.resolve(it) }
Logger.log.log(Level.FINE, "Found collection", info)
// remember usable collections
if ((service.type == Service.TYPE_CARDDAV && info.type == Collection.TYPE_ADDRESSBOOK) ||
(service.type == Service.TYPE_CALDAV && arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(info.type)))
collections[response.href] = info
}
} catch(e: HttpException) {
if (e.code in arrayOf(403, 404, 410))
// delete home set only if it was not accessible (40x)
itHomeSets.remove()
}
}
// check/refresh unconfirmed collections
val collectionsIter = collections.entries.iterator()
while (collectionsIter.hasNext()) {
val currentCollection = collectionsIter.next()
val (url, info) = currentCollection
if (!info.confirmed)
try {
// this collection doesn't belong to a homeset anymore, otherwise it would have been confirmed
info.homeSetId = null
DavResource(httpClient, url).propfind(0, *DAV_COLLECTION_PROPERTIES) { response, _ ->
if (!response.isSuccess())
return@propfind
val collection = Collection.fromDavResponse(response) ?: return@propfind
collection.serviceId = info.serviceId // use same service ID as previous entry
collection.confirmed = true
// remove unusable collections
if ((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))
collectionsIter.remove()
else
// update this collection in list
currentCollection.setValue(collection)
}
} catch(e: HttpException) {
if (e.code in arrayOf(403, 404, 410))
// delete collection only if it was not accessible (40x)
collectionsIter.remove()
else
throw e
}
}
// also check/refresh collections without a homeset
refresher.refreshHomelessCollections()
}
db.runInTransaction {
saveHomesets()
// use refHomeSet (if available) to determine homeset ID
for (collection in collections.values)
collection.refHomeSet?.let { homeSet ->
collection.homeSetId = homeSet.id
}
saveCollections()
}
} catch(e: InvalidAccountException) {
Logger.log.log(Level.SEVERE, "Invalid account", e)
return Result.failure()
@ -382,4 +188,241 @@ class RefreshCollectionsWorker @AssistedInject constructor(
return Result.success()
}
override fun onStopped() {
Logger.log.info("Stopping refresh")
refreshThread?.interrupt()
}
override fun getForegroundInfoAsync(): ListenableFuture<ForegroundInfo> =
CallbackToFutureAdapter.getFuture { completer ->
val notification = NotificationUtils.newBuilder(applicationContext, NotificationUtils.CHANNEL_STATUS)
.setSmallIcon(R.drawable.ic_foreground_notify)
.setContentTitle(applicationContext.getString(R.string.foreground_service_notify_title))
.setContentText(applicationContext.getString(R.string.foreground_service_notify_text))
.setStyle(NotificationCompat.BigTextStyle())
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
completer.set(ForegroundInfo(NotificationUtils.NOTIFY_SYNC_EXPEDITED, notification))
}
/**
* Contains the methods, which do the actual refreshing work. Collected here for testability
*/
class Refresher(
val db: AppDatabase,
val service: Service,
val settings: SettingsManager,
val httpClient: OkHttpClient
) {
private fun getHomesets(serviceId: Long) = db.homeSetDao().getByService(serviceId).associateBy { it.url }.toMutableMap()
private fun getHomelessCollections(serviceId: Long) = db.collectionDao().getByServiceAndHomeset(serviceId, null).associateBy { it.url }.toMutableMap()
private fun deleteHomeset(homeset: HomeSet) = db.homeSetDao().delete(homeset)
private fun deleteCollection(collection: Collection) = db.collectionDao().delete(collection)
private fun saveHomeset(homeset: HomeSet) = db.homeSetDao().insertOrUpdateByUrl(homeset)
private fun saveCollection(newCollection: Collection) {
// Entity relation: use refHomeSet (if available) to determine homeset ID
newCollection.refHomeSet?.let { homeSet ->
newCollection.homeSetId = homeSet.id
}
// remember locally set flags
db.collectionDao().getByServiceAndUrl(newCollection.serviceId, newCollection.url.toString())?.let { oldCollection ->
newCollection.sync = oldCollection.sync
newCollection.forceReadOnly = oldCollection.forceReadOnly
}
// commit to database
db.collectionDao().insertOrUpdateByUrl(newCollection)
}
/**
* Checks if the given URL defines home sets and adds them to given home set list.
*
* @param url Principal URL to query
* @param personal Whether this is the "outer" call of the recursion.
*
* *true* = found home sets belong to the current-user-principal; recurse if
* calendar proxies or group memberships are found
*
* *false* = found home sets don't directly belong to the current-user-principal; don't recurse
*
* @throws java.io.IOException
* @throws HttpException
* @throws at.bitfire.dav4jvm.exception.DavException
*/
internal fun queryHomeSets(url: HttpUrl, personal: Boolean = true) {
val related = mutableSetOf<HttpUrl>()
// Define homeset class and properties to look for
val homeSetClass: Class<out HrefListProperty>
val properties: Array<Property.Name>
when (service.type) {
Service.TYPE_CARDDAV -> {
homeSetClass = AddressbookHomeSet::class.java
properties = arrayOf(DisplayName.NAME, AddressbookHomeSet.NAME, GroupMembership.NAME)
}
Service.TYPE_CALDAV -> {
homeSetClass = CalendarHomeSet::class.java
properties = arrayOf(DisplayName.NAME, CalendarHomeSet.NAME, CalendarProxyReadFor.NAME, CalendarProxyWriteFor.NAME, GroupMembership.NAME)
}
else -> throw IllegalArgumentException()
}
val dav = DavResource(httpClient, url)
try {
// Query for the given service with properties
dav.propfind(0, *properties) { davResponse, _ ->
// Check we got back the right service and save it
davResponse[homeSetClass]?.let { homeSet ->
for (href in homeSet.hrefs)
dav.location.resolve(href)?.let {
val foundUrl = UrlUtils.withTrailingSlash(it)
saveHomeset(HomeSet(0, service.id, personal, foundUrl))
}
}
// If personal (outer call of recursion), find/refresh related resources
if (personal) {
val relatedResourcesTypes = mapOf(
CalendarProxyReadFor::class.java to "read-only proxy for", // calendar-proxy-read-for
CalendarProxyWriteFor::class.java to "read/write proxy for ", // calendar-proxy-read/write-for
GroupMembership::class.java to "member of group") // direct group memberships
for ((type, logString) in relatedResourcesTypes) {
davResponse[type]?.let {
for (href in it.hrefs) {
Logger.log.fine("Principal is a $logString for $href, checking for home sets")
dav.location.resolve(href)?.let { url ->
related += url
}
}
}
}
}
}
} catch (e: HttpException) {
if (e.code/100 == 4)
Logger.log.log(Level.INFO, "Ignoring Client Error 4xx while looking for ${service.type} home sets", e)
else
throw e
}
// query related homesets (those that do not belong to the current-user-principal)
for (resource in related)
queryHomeSets(resource, false)
}
/**
* Refreshes homesets and their collections.
*
* Each stored homeset URL is queried (propfind) and it's collections ([MultiResponseCallback]) either saved, updated
* or marked as homeless - in case a collection was removed from its homeset.
*
* If a homeset URL in fact points to a collection directly, the collection will be saved with this URL,
* and a null value for it's homeset. Refreshing of collections without homesets is then handled by [refreshHomelessCollections].
*/
internal fun refreshHomesetsAndTheirCollections() {
getHomesets(service.id).forEach { (homeSetUrl, localHomeset) ->
Logger.log.fine("Listing home set $homeSetUrl")
// To find removed collections in this homeset: create a queue of 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).toMutableList()
try {
DavResource(httpClient, homeSetUrl).propfind(1, *DAV_COLLECTION_PROPERTIES) { response, relation ->
// NB: This callback may be called multiple times ([MultiResponseCallback])
if (!response.isSuccess())
return@propfind
if (relation == Response.HrefRelation.SELF) {
// this response is about the homeset itself
localHomeset.displayName = response[DisplayName::class.java]?.displayName
localHomeset.privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind ?: true
saveHomeset(localHomeset)
}
// in any case, check whether the response is about a usable collection
val collection = Collection.fromDavResponse(response) ?: return@propfind
collection.serviceId = service.id
collection.refHomeSet = localHomeset
collection.sync = settings.getBoolean(Settings.SYNC_ALL_COLLECTIONS)
collection.owner = response[Owner::class.java]?.href?.let { response.href.resolve(it) }
Logger.log.log(Level.FINE, "Found collection", collection)
// save or update collection if usable (ignore it otherwise)
if (isUsableCollection(collection))
saveCollection(collection)
// Remove this collection from queue
localHomesetCollections.remove(collection)
}
} catch (e: HttpException) {
// delete home set locally if it was not accessible (40x)
if (e.code in arrayOf(403, 404, 410))
deleteHomeset(localHomeset)
}
// Mark leftover (not rediscovered) collections from queue as homeless (remove homeset association)
for (homelessCollection in localHomesetCollections) {
homelessCollection.homeSetId = null
homelessCollection.refHomeSet = null
saveCollection(homelessCollection)
}
}
}
/**
* Refreshes collections which don't have a homeset.
*
* It queries each stored collection with a homeSetId of "null" and either updates or deletes (if inaccessible or unusable) them.
*/
internal fun refreshHomelessCollections() {
getHomelessCollections(service.id).forEach { (url, localCollection) ->
try {
DavResource(httpClient, url).propfind(0, *DAV_COLLECTION_PROPERTIES) { response, _ ->
if (!response.isSuccess()) {
deleteCollection(localCollection)
return@propfind
}
// Save or update the collection, if usable, otherwise delete it
Collection.fromDavResponse(response)?.let { collection ->
if (!isUsableCollection(collection))
return@let
collection.serviceId = localCollection.serviceId // use same service ID as previous entry
saveCollection(collection)
} ?: deleteCollection(localCollection)
}
} catch (e: HttpException) {
// delete collection locally if it was not accessible (40x)
if (e.code in arrayOf(403, 404, 410))
deleteCollection(localCollection)
else
throw e
}
}
}
/**
* 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)
}
}

View file

@ -305,7 +305,7 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList
}
// observe RefreshCollectionsWorker status
val isRefreshing = RefreshCollectionsWorker.isWorkerInState(context, serviceId, WorkInfo.State.RUNNING)
val isRefreshing = RefreshCollectionsWorker.isWorkerInState(context, RefreshCollectionsWorker.workerName(serviceId), WorkInfo.State.RUNNING)
// observe whether sync framework is active
private var syncFrameworkStatusHandle: Any? = null

View file

@ -229,15 +229,14 @@ class AccountDetailsFragment : Fragment() {
// insert home sets
val homeSetDao = db.homeSetDao()
for (homeSet in info.homeSets) {
homeSetDao.insertOrReplace(HomeSet(0, serviceId, true, homeSet))
}
for (homeSet in info.homeSets)
homeSetDao.insertOrUpdateByUrl(HomeSet(0, serviceId, true, homeSet))
// insert collections
val collectionDao = db.collectionDao()
for (collection in info.collections.values) {
collection.serviceId = serviceId
collectionDao.insertOrReplace(collection)
collectionDao.insertOrUpdateByUrl(collection)
}
return serviceId