show real name of davowner (bitfireAT/davx5#208)

* add principal table with dao

* add principal table with dao

* collection saves ownerId instead of owner URL

* save and refresh principals

* show display name of collection owner in GUI

* show only the owner name (preferably) or respective url

* remove principals which do not own any collections

* Don't mock AppDatabase

* ensure we are really dealing with a principal and save it even without its display name

* ensure owner label is hidden when neither owner-displayname nor  owner-url are available

* save principal urls without trailing slash

* use a custom query to find principals without collections

* Some changes

- insertOrUpdateByUrl
- don't explicitly set id=0 when not necessary,
- make it work when there are already entries with trailing slahes
- added TODOs

* Small changes
- Update principal only if display name changed
- Rename methods
- Kdoc
- Tests

* stop using simple methods with vague names

* rename method insertOrUpdateOrGet to insertOrUpdate and leave existing kdoc for explanation

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
This commit is contained in:
Sunik Kupfer 2023-02-20 13:11:51 +01:00 committed by Ricki Hirner
parent 26961d9749
commit b83d116f40
No known key found for this signature in database
GPG key ID: 79A019FCAAEDD3AA
15 changed files with 1127 additions and 112 deletions

View file

@ -0,0 +1,615 @@
{
"formatVersion": 1,
"database": {
"version": 12,
"identityHash": "67fafceecee2d97cac6a62d46fa2c3e2",
"entities": [
{
"tableName": "service",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountName",
"columnName": "accountName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "principal",
"columnName": "principal",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_service_accountName_type",
"unique": true,
"columnNames": [
"accountName",
"type"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)"
}
],
"foreignKeys": []
},
{
"tableName": "homeset",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "serviceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "personal",
"columnName": "personal",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "privBind",
"columnName": "privBind",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_homeset_serviceId_url",
"unique": true,
"columnNames": [
"serviceId",
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
}
],
"foreignKeys": [
{
"table": "service",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serviceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "collection",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `ownerId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `color` INTEGER, `timezone` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`ownerId`) REFERENCES `principal`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "serviceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "homeSetId",
"columnName": "homeSetId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "ownerId",
"columnName": "ownerId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "privWriteContent",
"columnName": "privWriteContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "privUnbind",
"columnName": "privUnbind",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "forceReadOnly",
"columnName": "forceReadOnly",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "timezone",
"columnName": "timezone",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "supportsVEVENT",
"columnName": "supportsVEVENT",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "supportsVTODO",
"columnName": "supportsVTODO",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "supportsVJOURNAL",
"columnName": "supportsVJOURNAL",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sync",
"columnName": "sync",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_collection_serviceId_type",
"unique": false,
"columnNames": [
"serviceId",
"type"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)"
},
{
"name": "index_collection_homeSetId_type",
"unique": false,
"columnNames": [
"homeSetId",
"type"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)"
},
{
"name": "index_collection_url",
"unique": false,
"columnNames": [
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)"
}
],
"foreignKeys": [
{
"table": "service",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serviceId"
],
"referencedColumns": [
"id"
]
},
{
"table": "homeset",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"homeSetId"
],
"referencedColumns": [
"id"
]
},
{
"table": "principal",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"ownerId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "principal",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `url` TEXT NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "serviceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_principal_serviceId_url",
"unique": true,
"columnNames": [
"serviceId",
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_principal_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
}
],
"foreignKeys": [
{
"table": "service",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serviceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "syncstats",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `authority` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "collectionId",
"columnName": "collectionId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authority",
"columnName": "authority",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastSync",
"columnName": "lastSync",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_syncstats_collectionId_authority",
"unique": true,
"columnNames": [
"collectionId",
"authority"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_authority` ON `${TABLE_NAME}` (`collectionId`, `authority`)"
}
],
"foreignKeys": [
{
"table": "collection",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"collectionId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "webdav_document",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mountId` INTEGER NOT NULL, `parentId` INTEGER, `name` TEXT NOT NULL, `isDirectory` INTEGER NOT NULL, `displayName` TEXT, `mimeType` TEXT, `eTag` TEXT, `lastModified` INTEGER, `size` INTEGER, `mayBind` INTEGER, `mayUnbind` INTEGER, `mayWriteContent` INTEGER, `quotaAvailable` INTEGER, `quotaUsed` INTEGER, FOREIGN KEY(`mountId`) REFERENCES `webdav_mount`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parentId`) REFERENCES `webdav_document`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mountId",
"columnName": "mountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "parentId",
"columnName": "parentId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isDirectory",
"columnName": "isDirectory",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "eTag",
"columnName": "eTag",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastModified",
"columnName": "lastModified",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "size",
"columnName": "size",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "mayBind",
"columnName": "mayBind",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "mayUnbind",
"columnName": "mayUnbind",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "mayWriteContent",
"columnName": "mayWriteContent",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "quotaAvailable",
"columnName": "quotaAvailable",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "quotaUsed",
"columnName": "quotaUsed",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_webdav_document_mountId_parentId_name",
"unique": true,
"columnNames": [
"mountId",
"parentId",
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_webdav_document_mountId_parentId_name` ON `${TABLE_NAME}` (`mountId`, `parentId`, `name`)"
}
],
"foreignKeys": [
{
"table": "webdav_mount",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"mountId"
],
"referencedColumns": [
"id"
]
},
{
"table": "webdav_document",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"parentId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "webdav_mount",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '67fafceecee2d97cac6a62d46fa2c3e2')"
]
}
}

View file

@ -5,8 +5,6 @@
package at.bitfire.davdroid
import android.content.Context
import androidx.room.Room
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.settings.SettingsManager
import dagger.Module
import dagger.Provides
@ -20,17 +18,11 @@ import javax.inject.Singleton
@TestInstallIn(
components = [ SingletonComponent::class ],
replaces = [
AppDatabase.AppDatabaseModule::class,
SettingsManager.SettingsManagerModule::class
]
)
class MockingModule {
@Provides
@Singleton
fun spykAppDatabase(@ApplicationContext context: Context): AppDatabase =
spyk(Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build())
@Provides
@Singleton
fun spykSettingsManager(@ApplicationContext context: Context): SettingsManager =

View file

@ -4,6 +4,7 @@
package at.bitfire.davdroid.db
import android.content.Context
import androidx.room.Room
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
@ -15,27 +16,34 @@ class AppDatabaseTest {
val TEST_DB = "test"
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
@Rule
@JvmField
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java.canonicalName,
FrameworkSQLiteOpenHelperFactory()
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java,
listOf(), // no auto migrations until v8
FrameworkSQLiteOpenHelperFactory()
)
@Test
fun testAllMigrations() {
// DB schema is available since version 8
// DB schema is available since version 8, so create DB with v8
helper.createDatabase(TEST_DB, 8).close()
Room.databaseBuilder(
InstrumentationRegistry.getInstrumentation().targetContext,
AppDatabase::class.java,
TEST_DB
).addMigrations(*AppDatabase.migrations).build().apply {
openHelper.writableDatabase
close()
val db = Room.databaseBuilder(context, AppDatabase::class.java, TEST_DB)
// manual migrations
.addMigrations(*AppDatabase.migrations)
// auto-migrations that need to be specified explicitly
.addAutoMigrationSpec(AppDatabase.AutoMigration11_12(context))
.build()
try {
// open (with version 8) + migrate (to current version) database
db.openHelper.writableDatabase
} finally {
db.close()
}
}

View file

@ -4,26 +4,32 @@
package at.bitfire.davdroid.db
import androidx.room.Room
import androidx.test.platform.app.InstrumentationRegistry
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class HomesetDaoTest {
private lateinit var db: AppDatabase
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Inject
lateinit var db: AppDatabase
@Before
fun createDb() {
val context = InstrumentationRegistry.getInstrumentation().context
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build()
fun setUp() {
hiltRule.inject()
}
@After
fun closeDb() {
fun tearDown() {
db.close()
}

View file

@ -0,0 +1,33 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.db
import android.content.Context
import androidx.room.Room
import dagger.Module
import dagger.Provides
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import javax.inject.Singleton
@Module
@TestInstallIn(
components = [ SingletonComponent::class ],
replaces = [
AppDatabase.AppDatabaseModule::class
]
)
class MemoryDbModule {
@Provides
@Singleton
fun inMemoryDatabase(@ApplicationContext context: Context): AppDatabase =
Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
// auto-migrations that need to be specified explicitly
.addAutoMigrationSpec(AppDatabase.AutoMigration11_12(context))
.build()
}

View file

@ -12,15 +12,10 @@ 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.*
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
@ -31,6 +26,7 @@ import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.apache.commons.lang3.StringUtils
import org.junit.*
import org.junit.Assert.*
@ -70,9 +66,9 @@ class RefreshCollectionsWorkerTest {
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_PRINCIPAL_WITHOUT_COLLECTIONS = "/principal2"
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"
@ -139,10 +135,8 @@ class RefreshCollectionsWorkerTest {
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)
}
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)
@ -171,6 +165,7 @@ class RefreshCollectionsWorkerTest {
1,
service.id,
homesetId,
1, // will have gotten an owner too
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
@ -190,6 +185,7 @@ class RefreshCollectionsWorkerTest {
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
@ -207,6 +203,7 @@ class RefreshCollectionsWorkerTest {
collectionId,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
@ -226,6 +223,7 @@ class RefreshCollectionsWorkerTest {
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
@ -245,6 +243,7 @@ class RefreshCollectionsWorkerTest {
collectionId,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
@ -271,6 +270,7 @@ class RefreshCollectionsWorkerTest {
0,
service.id,
homesetId,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
@ -284,6 +284,43 @@ class RefreshCollectionsWorkerTest {
assertEquals(null, db.collectionDao().get(collectionId)!!.homeSetId)
}
@Test
fun refreshHomesetsAndTheirCollections_addsOwnerUrls() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// save a homeset in DB
val homesetId = db.homeSetDao().insert(
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
)
// place collection in DB - as part of the homeset
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
homesetId, // part of above home set
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
)
// Refresh - homesets and their collections
assertEquals(0, db.principalDao().get(service.id).size)
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshHomesetsAndTheirCollections()
// Check principal saved and the collection was updated with its reference
val principals = db.principalDao().get(service.id)
assertEquals(1, principals.size)
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
assertEquals(null, principals[0].displayName)
assertEquals(
principals[0].id,
db.collectionDao().get(collectionId)!!.ownerId
)
}
// refreshHomelessCollections
@ -297,6 +334,7 @@ class RefreshCollectionsWorkerTest {
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
)
@ -312,6 +350,7 @@ class RefreshCollectionsWorkerTest {
collectionId,
service.id,
null,
1, // will have gotten an owner too
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
@ -331,6 +370,7 @@ class RefreshCollectionsWorkerTest {
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_INACCESSIBLE")
)
@ -343,10 +383,104 @@ class RefreshCollectionsWorkerTest {
// Check the collection got deleted
assertEquals(null, db.collectionDao().get(collectionId))
}
@Test
fun refreshHomelessCollections_addsOwnerUrls() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// place homeless collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
)
)
// Refresh homeless collections
assertEquals(0, db.principalDao().get(service.id).size)
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshHomelessCollections()
// Check principal saved and the collection was updated with its reference
val principals = db.principalDao().get(service.id)
assertEquals(1, principals.size)
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
assertEquals(null, principals[0].displayName)
assertEquals(
principals[0].id,
db.collectionDao().get(collectionId)!!.ownerId
)
}
// refreshPrincipals
@Test
fun refreshPrincipals_updatesPrincipal() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// place principal without display name in db
val principalId = db.principalDao().insert(
Principal(
0,
service.id,
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), // no trailing slash
null // no display name for now
)
)
// add an associated collection - as the principal is rightfully removed otherwise
db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
principalId, // create association with principal
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash
)
)
// Refresh principals
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshPrincipals()
// Check principal now got a display name
val principals = db.principalDao().get(service.id)
assertEquals(1, principals.size)
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
assertEquals("Mr. Wobbles", principals[0].displayName)
}
@Test
fun refreshPrincipals_deletesPrincipalsWithoutCollections() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// place principal without collections in DB
db.principalDao().insert(
Principal(
0,
service.id,
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS/")
)
)
// Refresh principals - detecting it does not own collections
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshPrincipals()
// Check principal was deleted
val principals = db.principalDao().get(service.id)
assertEquals(0, principals.size)
}
// Test helpers and dependencies
fun createTestService(serviceType: String) : Service? {
private 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)
@ -355,7 +489,7 @@ class RefreshCollectionsWorkerTest {
class TestDispatcher: Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val path = request.path!!
val path = StringUtils.removeEnd(request.path!!, "/")
if (request.method.equals("PROPFIND", true)) {
val properties = when (path) {
@ -366,18 +500,29 @@ class RefreshCollectionsWorkerTest {
"</current-user-principal>"
PATH_CARDDAV + SUBPATH_PRINCIPAL ->
"<resourcetype><principal/></resourcetype>" +
"<displayname>Mr. Wobbles</displayname>" +
"<CARD:addressbook-home-set>" +
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET}</href>" +
"</CARD:addressbook-home-set>"
PATH_CARDDAV + SUBPATH_ADDRESSBOOK + "/",
PATH_CARDDAV + SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS ->
"<CARD:addressbook-home-set>" +
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_EMPTY}</href>" +
"</CARD:addressbook-home-set>" +
"<displayname>Mr. Wobbles Jr.</displayname>"
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>"
"<CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
"<owner>" +
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" +
"</owner>"
PATH_CALDAV + SUBPATH_PRINCIPAL ->
"<CAL:calendar-user-address-set>" +
@ -386,11 +531,13 @@ class RefreshCollectionsWorkerTest {
" <href>mailto:email2@example.com</href>" +
"</CAL:calendar-user-address-set>"
SUBPATH_ADDRESSBOOK_HOMESET_EMPTY -> ""
else -> ""
}
var responseBody: String = ""
var responseCode: Int = 207
var responseBody = ""
var responseCode = 207
when (path) {
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET ->
responseBody =

View file

@ -13,11 +13,13 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.database.getStringOrNull
import androidx.room.*
import androidx.room.migration.AutoMigrationSpec
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import at.bitfire.davdroid.R
import at.bitfire.davdroid.TextTable
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.ui.AccountsActivity
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible
@ -34,12 +36,14 @@ import javax.inject.Singleton
Service::class,
HomeSet::class,
Collection::class,
Principal::class,
SyncStats::class,
WebDavDocument::class,
WebDavMount::class
], exportSchema = true, version = 11, autoMigrations = [
], exportSchema = true, version = 12, autoMigrations = [
AutoMigration(from = 9, to = 10),
AutoMigration(from = 10, to = 11)
AutoMigration(from = 10, to = 11),
AutoMigration(from = 11, to = 12, spec = AppDatabase.AutoMigration11_12::class)
])
@TypeConverters(Converters::class)
abstract class AppDatabase: RoomDatabase() {
@ -52,6 +56,7 @@ abstract class AppDatabase: RoomDatabase() {
fun appDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "services.db")
.addMigrations(*migrations)
.addAutoMigrationSpec(AutoMigration11_12(context))
.fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing
.addCallback(object: Callback() {
override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
@ -76,9 +81,26 @@ abstract class AppDatabase: RoomDatabase() {
.build()
}
// auto migrations
@ProvidedAutoMigrationSpec
@DeleteColumn(tableName = "collection", columnName = "owner")
class AutoMigration11_12(val context: Context): AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
Logger.log.info("Database update to v12, refreshing services to get display names of owners")
db.query("SELECT id FROM service", arrayOf()).use { cursor ->
while (cursor.moveToNext()) {
val serviceId = cursor.getLong(0)
RefreshCollectionsWorker.refreshCollections(context, serviceId)
}
}
}
}
companion object {
// migrations
// manual migrations
val migrations: Array<Migration> = arrayOf(
object : Migration(8, 9) {
@ -233,6 +255,7 @@ abstract class AppDatabase: RoomDatabase() {
abstract fun serviceDao(): ServiceDao
abstract fun homeSetDao(): HomeSetDao
abstract fun collectionDao(): CollectionDao
abstract fun principalDao(): PrincipalDao
abstract fun syncStatsDao(): SyncStatsDao
abstract fun webDavDocumentDao(): WebDavDocumentDao
abstract fun webDavMountDao(): WebDavMountDao

View file

@ -16,7 +16,8 @@ import org.apache.commons.lang3.StringUtils
@Entity(tableName = "collection",
foreignKeys = [
ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE),
ForeignKey(entity = HomeSet::class, parentColumns = arrayOf("id"), childColumns = arrayOf("homeSetId"), onDelete = ForeignKey.SET_NULL)
ForeignKey(entity = HomeSet::class, parentColumns = arrayOf("id"), childColumns = arrayOf("homeSetId"), onDelete = ForeignKey.SET_NULL),
ForeignKey(entity = Principal::class, parentColumns = arrayOf("id"), childColumns = arrayOf("ownerId"), onDelete = ForeignKey.SET_NULL)
],
indices = [
Index("serviceId","type"),
@ -40,13 +41,18 @@ data class Collection(
*/
var homeSetId: Long? = null,
/**
* Principal who is owner of this collection.
*/
var ownerId: Long? = null,
/**
* Type of service. CalDAV or CardDAV
*/
var type: String,
/**
* Address where this collection lives
* Address where this collection lives - with trailing slash
*/
var url: HttpUrl,
@ -56,7 +62,6 @@ data class Collection(
var displayName: String? = null,
var description: String? = null,
var owner: HttpUrl? = null,
// CalDAV only
var color: Int? = null,
@ -111,9 +116,6 @@ data class Collection(
}
val displayName = StringUtils.trimToNull(dav[DisplayName::class.java]?.displayName)
val owner = dav[Owner::class.java]?.href?.let { ownerHref ->
dav.href.resolve(ownerHref)
}
var description: String? = null
var color: Int? = null
@ -160,7 +162,6 @@ data class Collection(
privWriteContent = privWriteContent,
privUnbind = privUnbind,
displayName = displayName,
owner = owner,
description = description,
color = color,
timezone = timezone,

View file

@ -71,6 +71,7 @@ interface CollectionDao {
* 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!
*
* @param collection Collection to be inserted or updated
* @return ID of the row, that has been inserted or updated. -1 If the insert fails due to other reasons.
*/
@Transaction
@ -82,6 +83,23 @@ interface CollectionDao {
localCollection.id
} ?: insert(collection)
/**
* Inserts or updates the collection. On update it will not update flag values ([Collection.sync],
* [Collection.forceReadOnly]), but use the values of the already existing collection.
*
* @param newCollection Collection to be inserted or updated
*/
fun insertOrUpdateByUrlAndRememberFlags(newCollection: Collection) {
// remember locally set flags
getByServiceAndUrl(newCollection.serviceId, newCollection.url.toString())?.let { oldCollection ->
newCollection.sync = oldCollection.sync
newCollection.forceReadOnly = oldCollection.forceReadOnly
}
// commit to database
insertOrUpdateByUrl(newCollection)
}
@Delete
fun delete(collection: Collection)

View file

@ -0,0 +1,69 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.db
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.dav4jvm.property.DisplayName
import at.bitfire.dav4jvm.property.ResourceType
import okhttp3.HttpUrl
import org.apache.commons.lang3.StringUtils
@Entity(tableName = "principal",
foreignKeys = [
ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE)
],
indices = [
// index by service, urls are unique
Index("serviceId", "url", unique = true)
]
)
data class Principal(
@PrimaryKey(autoGenerate = true)
var id: Long = 0,
var serviceId: Long,
/** URL of the principal, always without trailing slash */
var url: HttpUrl,
var displayName: String? = null
) {
companion object {
/**
* Generates a principal entity from a WebDAV response.
* @param dav WebDAV response (make sure that you have queried `DAV:resource-type` and `DAV:display-name`)
* @return generated principal data object (with `id`=0), `null` if the response doesn't represent a principal
*/
fun fromDavResponse(serviceId: Long, dav: Response): Principal? {
// Check if response is a principal
val resourceType = dav[ResourceType::class.java] ?: return null
if (!resourceType.types.contains(ResourceType.PRINCIPAL))
return null
// Try getting the display name of the principal
val displayName: String? = StringUtils.trimToNull(
dav[DisplayName::class.java]?.displayName
)
// Create and return principal - even without it's display name
return Principal(
serviceId = serviceId,
url = UrlUtils.omitTrailingSlash(dav.href),
displayName = displayName
)
}
fun fromServiceAndUrl(service: Service, url: HttpUrl) = Principal(
serviceId = service.id,
url = UrlUtils.omitTrailingSlash(url)
)
}
}

View file

@ -0,0 +1,52 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.db
import androidx.lifecycle.LiveData
import androidx.room.*
import okhttp3.HttpUrl
@Dao
interface PrincipalDao {
@Query("SELECT * FROM principal WHERE serviceId=:serviceId")
fun get(serviceId: Long): List<Principal>
@Query("SELECT * FROM principal WHERE id=:id")
fun getLive(id: Long): LiveData<Principal>
@Query("SELECT * FROM principal WHERE serviceId=:serviceId AND url=:url")
fun getByUrl(serviceId: Long, url: HttpUrl): Principal?
/**
* Gets all principals who do not own any collections
*/
@Query("SELECT * FROM principal WHERE principal.id NOT IN (SELECT ownerId FROM collection WHERE ownerId IS NOT NULL)")
fun getAllWithoutCollections(): List<Principal>
@Insert
fun insert(principal: Principal): Long
@Update
fun update(principal: Principal)
@Delete
fun delete(principal: Principal)
/**
* Inserts, updates or just gets existing principal if its display name has not
* changed (will not update/overwrite with null values).
*
* @param principal Principal to be inserted or updated
* @return ID of the newly inserted or already existing principal
*/
fun insertOrUpdate(serviceId: Long, principal: Principal): Long =
getByUrl(serviceId, principal.url)?.let { oldPrincipal ->
if (principal.displayName != oldPrincipal.displayName)
update(principal.copy(id = oldPrincipal.id))
return oldPrincipal.id
} ?: insert(principal)
}

View file

@ -31,7 +31,7 @@ class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Lo
put(JtxContract.JtxCollection.URL, info.url.toString())
put(JtxContract.JtxCollection.DISPLAYNAME, info.displayName ?: DavUtils.lastSegmentOfUrl(info.url))
put(JtxContract.JtxCollection.DESCRIPTION, info.description)
put(JtxContract.JtxCollection.OWNER, info.owner?.toString())
put(JtxContract.JtxCollection.OWNER, info.ownerId)
put(JtxContract.JtxCollection.COLOR, info.color)
put(JtxContract.JtxCollection.SUPPORTSVEVENT, info.supportsVEVENT)
put(JtxContract.JtxCollection.SUPPORTSVJOURNAL, info.supportsVJOURNAL)

View file

@ -19,11 +19,10 @@ 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.AppDatabase
import at.bitfire.davdroid.db.*
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.servicedetection.RefreshCollectionsWorker.Companion.ARG_SERVICE_ID
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
@ -77,6 +76,12 @@ class RefreshCollectionsWorker @AssistedInject constructor(
Source.NAME
)
// Principal properties to ask the server
val DAV_PRINCIPAL_PROPERTIES = arrayOf(
DisplayName.NAME,
ResourceType.NAME
)
/**
* Uniquely identifies a refresh worker. Useful for stopping work, or querying its state.
*
@ -149,17 +154,20 @@ class RefreshCollectionsWorker @AssistedInject constructor(
val httpClient = client.okHttpClient
val refresher = Refresher(db, service, settings, httpClient)
// refresh home set list (from principal url) and save them
// refresh home set list (from principal url)
service.principal?.let { principalUrl ->
Logger.log.fine("Querying principal $principalUrl for home sets")
refresher.queryHomeSets(principalUrl)
}
// now refresh home sets and their member collections
// refresh home sets and their member collections
refresher.refreshHomesetsAndTheirCollections()
// also check/refresh collections without a homeset
// also refresh collections without a home set
refresher.refreshHomelessCollections()
// Lastly, refresh the principals (collection owners)
refresher.refreshPrincipals()
}
} catch(e: InvalidAccountException) {
@ -219,24 +227,6 @@ class RefreshCollectionsWorker @AssistedInject constructor(
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) {
// 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.
*
@ -280,7 +270,7 @@ class RefreshCollectionsWorker @AssistedInject constructor(
for (href in homeSet.hrefs)
dav.location.resolve(href)?.let {
val foundUrl = UrlUtils.withTrailingSlash(it)
saveHomeset(HomeSet(0, service.id, personal, foundUrl))
db.homeSetDao().insertOrUpdateByUrl(HomeSet(0, service.id, personal, foundUrl))
}
}
@ -325,7 +315,8 @@ class RefreshCollectionsWorker @AssistedInject constructor(
* 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) ->
val homesets = db.homeSetDao().getByService(service.id).associateBy { it.url }.toMutableMap()
for((homeSetUrl, localHomeset) in homesets) {
Logger.log.fine("Listing home set $homeSetUrl")
// To find removed collections in this homeset: create a queue from existing collections and remove every collection that
@ -345,7 +336,7 @@ class RefreshCollectionsWorker @AssistedInject constructor(
// this response is about the homeset itself
localHomeset.displayName = response[DisplayName::class.java]?.displayName
localHomeset.privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind ?: true
saveHomeset(localHomeset)
db.homeSetDao().insertOrUpdateByUrl(localHomeset)
}
// in any case, check whether the response is about a usable collection
@ -354,13 +345,21 @@ class RefreshCollectionsWorker @AssistedInject constructor(
collection.serviceId = service.id
collection.homeSetId = localHomeset.id
collection.sync = settings.getBoolean(Settings.SYNC_ALL_COLLECTIONS)
collection.owner = response[Owner::class.java]?.href?.let { response.href.resolve(it) }
// .. and save the principal url (collection owner)
response[Owner::class.java]?.href
?.let { response.href.resolve(it) }
?.let { principalUrl ->
val principal = Principal.fromServiceAndUrl(service, principalUrl)
val id = db.principalDao().insertOrUpdate(service.id, principal)
collection.ownerId = id
}
Logger.log.log(Level.FINE, "Found collection", collection)
// save or update collection if usable (ignore it otherwise)
if (isUsableCollection(collection))
saveCollection(collection)
db.collectionDao().insertOrUpdateByUrlAndRememberFlags(collection)
// Remove this collection from queue - because it was found in the home set
localHomesetCollections.remove(collection.url)
@ -368,13 +367,13 @@ class RefreshCollectionsWorker @AssistedInject constructor(
} catch (e: HttpException) {
// delete home set locally if it was not accessible (40x)
if (e.code in arrayOf(403, 404, 410))
deleteHomeset(localHomeset)
db.homeSetDao().delete(localHomeset)
}
// Mark leftover (not rediscovered) collections from queue as homeless (remove association)
for ((_, homelessCollection) in localHomesetCollections) {
homelessCollection.homeSetId = null
saveCollection(homelessCollection)
db.collectionDao().insertOrUpdateByUrlAndRememberFlags(homelessCollection)
}
}
@ -386,29 +385,65 @@ class RefreshCollectionsWorker @AssistedInject constructor(
* 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)
val homelessCollections = db.collectionDao().getByServiceAndHomeset(service.id, null).associateBy { it.url }.toMutableMap()
for((url, localCollection) in homelessCollections) try {
DavResource(httpClient, url).propfind(0, *DAV_COLLECTION_PROPERTIES) { response, _ ->
if (!response.isSuccess()) {
db.collectionDao().delete(localCollection)
return@propfind
}
} 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
// 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
// .. and save the principal url (collection owner)
response[Owner::class.java]?.href
?.let { response.href.resolve(it) }
?.let { principalUrl ->
val principal = Principal.fromServiceAndUrl(service, principalUrl)
val principalId = db.principalDao().insertOrUpdate(service.id, principal)
collection.ownerId = principalId
}
db.collectionDao().insertOrUpdateByUrlAndRememberFlags(collection)
} ?: db.collectionDao().delete(localCollection)
}
} catch (e: HttpException) {
// delete collection locally if it was not accessible (40x)
if (e.code in arrayOf(403, 404, 410))
db.collectionDao().delete(localCollection)
else
throw e
}
}
/**
* Refreshes the principals (get their current display names).
* Also removes principals which do not own any collections anymore.
*/
internal fun refreshPrincipals() {
// Refresh principals (collection owner urls)
val principals = db.principalDao().get(service.id)
for (oldPrincipal in principals) {
val principalUrl = oldPrincipal.url
Logger.log.fine("Querying principal $principalUrl")
DavResource(httpClient, principalUrl).propfind(0, *DAV_PRINCIPAL_PROPERTIES) { response, _ ->
if (!response.isSuccess())
return@propfind
Principal.fromDavResponse(service.id, response)?.let { principal ->
Logger.log.fine("Got principal: $principal")
db.principalDao().insertOrUpdate(service.id, principal)
}
}
}
// Delete principals which don't own any collections
db.principalDao().getAllWithoutCollections().forEach {principal ->
db.principalDao().delete(principal)
}
}

View file

@ -10,6 +10,7 @@ import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import at.bitfire.davdroid.databinding.CollectionPropertiesBinding
@ -66,6 +67,11 @@ class CollectionInfoFragment: DialogFragment() {
}
var collection = db.collectionDao().getLive(collectionId)
var owner = Transformations.switchMap(collection) { collection ->
collection.ownerId?.let { ownerId ->
db.principalDao().getLive(ownerId)
}
}
}

View file

@ -53,19 +53,29 @@
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="4dp"
android:visibility="@{model.collection.owner != null ? View.VISIBLE : View.GONE}"
android:visibility="@{model.owner.displayName != null || model.owner.url != null ? View.VISIBLE : View.GONE}"
android:text="@string/collection_properties_owner"
android:labelFor="@id/owner" />
android:labelFor="@id/ownerDisplayName" />
<TextView
android:id="@+id/owner"
android:id="@+id/ownerDisplayName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="@{model.collection.owner != null ? View.VISIBLE : View.GONE}"
android:visibility="@{model.owner.displayName != null ? View.VISIBLE : View.GONE}"
android:textSize="16sp"
android:textIsSelectable="true"
android:text="@{model.owner.displayName}"
tools:text="Max Murmelmann"/>
<TextView
android:id="@+id/ownerUrl"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="@{model.owner.displayName == null &amp;&amp; model.owner.url != null ? View.VISIBLE : View.GONE}"
android:fontFamily="monospace"
android:textSize="12sp"
android:textIsSelectable="true"
android:text="@{model.collection.owner.toString()}"
android:text="@{model.owner.url.toString()}"
tools:text="/remote.php/dav/principals/users/sample-owner/"/>
</LinearLayout>