mirror of
https://github.com/bitfireAT/davx5-ose
synced 2024-07-23 19:50:18 +00:00
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:
parent
df2922f873
commit
62617a2889
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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())
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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" />
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
)
|
|
@ -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)
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
)
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue