remove DaoTools (for syncing) (bitfireAT/davx5#190)

* add test fixtures

* Use direct DB access instead of DaoTools

* minor changes and kdoc

* minor changes and kdoc

* remove obsolete DaoTools, SyncableDao and IdEntity classes

* KDoc

* use hashmap instead of list and added kdoc

* always load from database

* add a test and make test structur more flexible for future tests

* minor KDoc

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
This commit is contained in:
Sunik Kupfer 2023-01-31 13:33:19 +01:00 committed by Ricki Hirner
parent 2661dbba34
commit ca62efa2aa
No known key found for this signature in database
GPG key ID: 79A019FCAAEDD3AA
7 changed files with 451 additions and 217 deletions

View file

@ -0,0 +1,255 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.webdav
import android.content.Context
import android.security.NetworkSecurityPolicy
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.db.WebDavDocument
import at.bitfire.davdroid.db.WebDavMount
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.ui.setup.LoginModel
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.CookieJar
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.net.URI
import javax.inject.Inject
@HiltAndroidTest
class DavDocumentsProviderTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
@Inject lateinit var db: AppDatabase
@Before
fun setUp() {
hiltRule.inject()
}
private var mockServer = MockWebServer()
private lateinit var client: HttpClient
private lateinit var loginModel: LoginModel
companion object {
private const val PATH_WEBDAV_ROOT = "/webdav"
}
@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()
// mock server delivers HTTP without encryption
assertTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
}
@After
fun cleanUp() {
mockServer.shutdown()
db.close()
}
@Test
fun testDoQueryChildren_insert() {
// Create parent and root in database
val id = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", mockServer.url(PATH_WEBDAV_ROOT)))
val webDavMount = db.webDavMountDao().getById(id)
val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
val cookieStore = mutableMapOf<Long, CookieJar>()
// Query
DavDocumentsProvider.DavDocumentsActor(context, db, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
.queryChildren(parent)
// Assert new children were inserted into db
assertEquals(3, db.webDavDocumentDao().getChildren(parent.id).size)
assertEquals("Secret_Document.pages", db.webDavDocumentDao().getChildren(parent.id)[0].displayName)
assertEquals("MeowMeow_Cats.docx", db.webDavDocumentDao().getChildren(parent.id)[1].displayName)
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[2].displayName)
}
@Test
fun testDoQueryChildren_update() {
// Create parent and root in database
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", mockServer.url(PATH_WEBDAV_ROOT)))
val webDavMount = db.webDavMountDao().getById(mountId)
val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
val cookieStore = mutableMapOf<Long, CookieJar>()
assertEquals("Cat food storage", db.webDavDocumentDao().get(parent.id)!!.displayName)
// Create a folder
val folderId = db.webDavDocumentDao().insert(
WebDavDocument(
0,
mountId,
parent.id,
"My_Books",
true,
"My Books",
)
)
assertEquals("My_Books", db.webDavDocumentDao().get(folderId)!!.name)
assertEquals("My Books", db.webDavDocumentDao().get(folderId)!!.displayName)
// Query - should update the parent displayname and folder name
DavDocumentsProvider.DavDocumentsActor(context, db, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
.queryChildren(parent)
// Assert parent and children were updated in database
assertEquals("Cats WebDAV", db.webDavDocumentDao().get(parent.id)!!.displayName)
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[2].name)
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[2].displayName)
}
@Test
fun testDoQueryChildren_delete() {
// Create parent and root in database
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", mockServer.url(PATH_WEBDAV_ROOT)))
val webDavMount = db.webDavMountDao().getById(mountId)
val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
val cookieStore = mutableMapOf<Long, CookieJar>()
// Create a folder
val folderId = db.webDavDocumentDao().insert(
WebDavDocument(0, mountId, parent.id, "deleteme", true, "Should be deleted")
)
assertEquals("deleteme", db.webDavDocumentDao().get(folderId)!!.name)
// Query - discovers serverside deletion
DavDocumentsProvider.DavDocumentsActor(context, db, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
.queryChildren(parent)
// Assert folder got deleted
assertEquals(null, db.webDavDocumentDao().get(folderId))
}
@Test
fun testDoQueryChildren_updateTwoParentsSimultaneous() {
// Create root in database
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", mockServer.url(PATH_WEBDAV_ROOT)))
val webDavMount = db.webDavMountDao().getById(mountId)
val root = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
val cookieStore = mutableMapOf<Long, CookieJar>()
// Create two parents
val parent1Id = db.webDavDocumentDao().insert(WebDavDocument(0, mountId, root.id, "parent1", true))
val parent2Id = db.webDavDocumentDao().insert(WebDavDocument(0, mountId, root.id, "parent2", true))
val parent1 = db.webDavDocumentDao().get(parent1Id)!!
val parent2 = db.webDavDocumentDao().get(parent2Id)!!
assertEquals("parent1", parent1.name)
assertEquals("parent2", parent2.name)
// Query - find children of two nodes simultaneously
DavDocumentsProvider.DavDocumentsActor(context, db, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
.queryChildren(parent1)
DavDocumentsProvider.DavDocumentsActor(context, db, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
.queryChildren(parent2)
// Assert the two folders names have changed
assertEquals("childOne.txt", db.webDavDocumentDao().getChildren(parent1Id)[0].name)
assertEquals("childTwo.txt", db.webDavDocumentDao().getChildren(parent2Id)[0].name)
}
// mock server
class TestDispatcher: Dispatcher() {
data class Resource(
val name: String,
val props: String
)
override fun dispatch(request: RecordedRequest): MockResponse {
val requestPath = request.path!!.trimEnd('/')
if (request.method.equals("PROPFIND", true)) {
val propsMap = mutableMapOf(
PATH_WEBDAV_ROOT to arrayOf(
Resource("",
"<resourcetype><collection/></resourcetype>" +
"<displayname>Cats WebDAV</displayname>"
),
Resource("Secret_Document.pages",
"<displayname>Secret_Document.pages</displayname>",
),
Resource("MeowMeow_Cats.docx",
"<displayname>MeowMeow_Cats.docx</displayname>"
),
Resource("Library",
"<resourcetype><collection/></resourcetype>" +
"<displayname>Library</displayname>"
)
),
"$PATH_WEBDAV_ROOT/parent1" to arrayOf(
Resource("childOne.txt",
"<displayname>childOne.txt</displayname>"
),
),
"$PATH_WEBDAV_ROOT/parent2" to arrayOf(
Resource("childTwo.txt",
"<displayname>childTwo.txt</displayname>"
)
)
)
val responses = propsMap[requestPath]?.joinToString { resource ->
"<response><href>$requestPath/${resource.name}</href><propstat><prop>" +
resource.props +
"</prop></propstat></response>"
}
val multistatus =
"<multistatus xmlns='DAV:' " +
"xmlns:CARD='urn:ietf:params:xml:ns:carddav' " +
"xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
responses +
"</multistatus>"
Logger.log.info("Query path: $requestPath")
Logger.log.info("Response: $multistatus")
return MockResponse()
.setResponseCode(207)
.setBody(multistatus)
}
return MockResponse().setResponseCode(404)
}
}
}

View file

@ -1,51 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
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 {
/**
* Synchronizes a list of "old" elements with a list of "new" elements so that the list
* only contain equal elements.
*
* @param allOld list of old elements
* @param allNew map of new elements (stored in key map)
* @param selectKey generates a unique key from the element (will be called on old elements)
* @param prepareNew prepares new elements (can be used to take over properties of old elements)
*/
fun <K> syncAll(allOld: List<T>, allNew: Map<K,T>, selectKey: (T) -> K, prepareNew: (new: T, old: T) -> Unit = { _, _ -> }) {
Logger.log.log(Level.FINE, "Syncing tables", arrayOf(allOld, allNew))
val remainingNew = allNew.toMutableMap()
allOld.forEach { old ->
val key = selectKey(old)
val matchingNew = remainingNew[key]
if (matchingNew != null) {
// keep this old item, but maybe update it
matchingNew.id = old.id // identity is proven by key
prepareNew(matchingNew, old)
if (matchingNew != old)
update(matchingNew)
// remove from remainingNew
remainingNew -= key
} else {
// this old item is not present anymore, delete it
delete(old)
}
}
val toInsert = remainingNew.values.toList()
val insertIds = insert(toInsert)
insertIds.withIndex().forEach { (idx, id) ->
toInsert[idx].id = id
}
}
}

View file

@ -1,14 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.db
/**
* A model with a primary ID. Must be overriden with `@PrimaryKey(autoGenerate = true)`.
* Required for [DaoTools] so that ID fields of all model classes have the same schema.
*/
@Deprecated("Use direct DB access instead")
interface IdEntity {
var id: Long
}

View file

@ -1,24 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.db
import androidx.room.Delete
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)
fun insert(items: List<T>): LongArray
@Update
fun update(item: T)
@Delete
fun delete(item: T)
}

View file

@ -31,7 +31,7 @@ import java.io.FileNotFoundException
data class WebDavDocument(
@PrimaryKey(autoGenerate = true)
override var id: Long = 0,
var id: Long = 0,
/** refers to the [WebDavMount] the document belongs to */
val mountId: Long,
@ -56,7 +56,7 @@ data class WebDavDocument(
var quotaAvailable: Long? = null,
var quotaUsed: Long? = null
): IdEntity {
) {
@SuppressLint("InlinedApi")
fun toBundle(parent: WebDavDocument?): Bundle {

View file

@ -8,7 +8,7 @@ import androidx.lifecycle.LiveData
import androidx.room.*
@Dao
interface WebDavDocumentDao: SyncableDao<WebDavDocument> {
interface WebDavDocumentDao {
@Query("SELECT * FROM webdav_document WHERE id=:id")
fun get(id: Long): WebDavDocument?
@ -28,9 +28,35 @@ interface WebDavDocumentDao: SyncableDao<WebDavDocument> {
@Query("DELETE FROM webdav_document WHERE parentId=:parentId")
fun removeChildren(parentId: Long)
@Insert
fun insert(document: WebDavDocument): Long
@Update
fun update(document: WebDavDocument)
@Delete
fun delete(document: WebDavDocument)
// complex operations
/**
* 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 insertOrUpdate(document: WebDavDocument): Long {
val parentId = document.parentId
?: return insert(document)
val existingDocument = getByParentAndName(document.mountId, parentId, document.name)
?: return insert(document)
update(document.copy(id = existingDocument.id))
return existingDocument.id
}
@Transaction
fun getOrCreateRoot(mount: WebDavMount): WebDavDocument {
getByParentAndName(mount.id, null, "")?.let { existing ->

View file

@ -17,7 +17,6 @@ import android.graphics.Point
import android.media.ThumbnailUtils
import android.net.ConnectivityManager
import android.os.Build
import android.os.Bundle
import android.os.CancellationSignal
import android.os.ParcelFileDescriptor
import android.os.storage.StorageManager
@ -35,7 +34,6 @@ import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.MemoryCookieStore
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.DaoTools
import at.bitfire.davdroid.db.WebDavDocument
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.ui.webdav.WebdavMountsActivity
@ -55,6 +53,11 @@ import java.util.concurrent.*
import java.util.logging.Level
import kotlin.math.min
/**
* Provides functionality on WebDav documents.
*
* Actual implementation should go into [DavDocumentsActor].
*/
class DavDocumentsProvider: DocumentsProvider() {
@EntryPoint
@ -92,9 +95,9 @@ class DavDocumentsProvider: DocumentsProvider() {
private val documentDao by lazy { db.webDavDocumentDao() }
private val credentialsStore by lazy { CredentialsStore(ourContext) }
val cookieStore by lazy { mutableMapOf<Long, CookieJar>() }
val headResponseCache by lazy { HeadResponseCache() }
val thumbnailCache by lazy { ThumbnailCache(ourContext) }
private val cookieStore by lazy { mutableMapOf<Long, CookieJar>() }
private val headResponseCache by lazy { HeadResponseCache() }
private val thumbnailCache by lazy { ThumbnailCache(ourContext) }
private val connectivityManager by lazy { ourContext.getSystemService<ConnectivityManager>()!! }
private val storageManager by lazy { ourContext.getSystemService<StorageManager>()!! }
@ -102,7 +105,14 @@ class DavDocumentsProvider: DocumentsProvider() {
private val executor by lazy {
ThreadPoolExecutor(1, min(Runtime.getRuntime().availableProcessors(), 4), 30, TimeUnit.SECONDS, BlockingLifoQueue())
}
private val runningQueryChildren = HashMap<Long, Future<List<Bundle>>>()
/** List of currently active [queryChildDocuments] runners.
*
* Key: document ID (directory) for which children are listed.
* Value: whether the runner is still running (*true*) or has already finished (*false*).
*/
private val runningQueryChildren = ConcurrentHashMap<Long, Boolean>()
private val actor by lazy { DavDocumentsActor(ourContext, db, cookieStore, credentialsStore, authority) }
override fun onCreate() = true
@ -180,6 +190,14 @@ class DavDocumentsProvider: DocumentsProvider() {
}
}
/**
* Gets old or new children of given parent.
*
* Dispatches a worker querying the server for new children of given parent, and instantly
* returns old children (or nothing, on initial call).
* Once the worker finishes its query, it notifies the [android.content.ContentResolver] about
* change, which calls this method again. The worker being done
*/
@Synchronized
override fun queryChildDocuments(parentDocumentId: String, projection: Array<out String>?, sortOrder: String?): Cursor {
Logger.log.fine("WebDAV queryChildDocuments $parentDocumentId $projection $sortOrder")
@ -194,107 +212,35 @@ class DavDocumentsProvider: DocumentsProvider() {
Document.COLUMN_SIZE,
Document.COLUMN_LAST_MODIFIED
)
// Register watcher
val result = DocumentsCursor(columns)
val notificationUri = buildChildDocumentsUri(authority, parentDocumentId)
val worker = runningQueryChildren.getOrPut(parentId) {
executor.submit(Callable {
val rows = doQueryChildren(parent)
ourContext.contentResolver.notifyChange(notificationUri, null)
return@Callable rows
})
}
if (!worker.isDone) {
// still loading, populate from cache
result.loading = true
for (child in documentDao.getChildren(parentId)) {
val bundle = child.toBundle(parent)
result.addRow(bundle)
}
} else {
try {
for (row in worker.get())
result.addRow(row)
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't query children", e)
if (e is ExecutionException) {
val cause = e.cause
if (cause is HttpException) {
result.error = "${cause.code} ${cause.message}"
} else
result.error = (cause ?: e).message
}
}
runningQueryChildren.remove(parentId)
}
result.setNotificationUri(ourContext.contentResolver, notificationUri)
return result
}
@WorkerThread
private fun doQueryChildren(parent: WebDavDocument): List<Bundle> {
val oldChildren = documentDao.getChildren(parent.id)
val newChildren = hashMapOf<String, WebDavDocument>()
httpClient(parent.mountId).use { client ->
val parentUrl = parent.toHttpUrl(db)
val folder = DavCollection(client.okHttpClient, parentUrl)
folder.propfind(1, *DAV_FILE_FIELDS) { response, relation ->
Logger.log.fine("$relation $response")
val resource: WebDavDocument =
when (relation) {
Response.HrefRelation.SELF -> // it's about the parent
parent
Response.HrefRelation.MEMBER -> // it's about a member
WebDavDocument(mountId = parent.mountId, parentId = parent.id, name = response.hrefName())
else ->
// we didn't request this; ignore it
return@propfind
}
response[ResourceType::class.java]?.types?.let { types ->
resource.isDirectory = types.contains(ResourceType.COLLECTION)
}
resource.displayName = response[DisplayName::class.java]?.displayName
resource.mimeType = response[GetContentType::class.java]?.type
response[GetETag::class.java]?.let { getETag ->
if (!getETag.weak)
resource.eTag = resource.eTag
}
resource.lastModified = response[GetLastModified::class.java]?.lastModified
resource.size = response[GetContentLength::class.java]?.contentLength
val privs = response[CurrentUserPrivilegeSet::class.java]
resource.mayBind = privs?.mayBind
resource.mayUnbind = privs?.mayUnbind
resource.mayWriteContent = privs?.mayWriteContent
resource.quotaAvailable = response[QuotaAvailableBytes::class.java]?.quotaAvailableBytes
resource.quotaUsed = response[QuotaUsedBytes::class.java]?.quotaUsedBytes
if (resource == parent)
documentDao.update(resource)
else
newChildren[resource.name] = resource
// Dispatch worker querying for the children and keep track of it
val running = runningQueryChildren.getOrPut(parentId) {
executor.submit {
actor.queryChildren(parent)
// Once the query is done, set query as finished (not running)
runningQueryChildren.put(parentId, false)
// .. and notify - effectively calling this method again
ourContext.contentResolver.notifyChange(notificationUri, null)
}
true
}
DaoTools(documentDao).syncAll(oldChildren, newChildren, selectKey = {
document -> document.name
})
if (running) // worker still running
result.loading = true
else // remove worker from list if done
runningQueryChildren.remove(parentId)
val result = ArrayList<Bundle>(newChildren.size)
for (child in newChildren.values) {
// Regardless of whether the worker is done, return the children we already have
for (child in documentDao.getChildren(parentId)) {
val bundle = child.toBundle(parent)
Logger.log.fine("Found child: $bundle")
result += bundle
result.addRow(bundle)
}
return result
}
@ -329,7 +275,7 @@ class DavDocumentsProvider: DocumentsProvider() {
throw UnsupportedOperationException("Can't COPY between WebDAV servers")
val dstDocId: String
httpClient(srcDoc.mountId).use { client ->
actor.httpClient(srcDoc.mountId).use { client ->
val dav = DavResource(client.okHttpClient, srcDoc.toHttpUrl(db))
try {
val dstUrl = dstFolder.toHttpUrl(db).newBuilder()
@ -349,7 +295,7 @@ class DavDocumentsProvider: DocumentsProvider() {
size = srcDoc.size
)).toString()
notifyFolderChanged(targetParentDocumentId)
actor.notifyFolderChanged(targetParentDocumentId)
} catch (e: HttpException) {
if (e.code == HttpURLConnection.HTTP_NOT_FOUND)
throw FileNotFoundException()
@ -367,7 +313,7 @@ class DavDocumentsProvider: DocumentsProvider() {
val createDirectory = mimeType == Document.MIME_TYPE_DIR
var docId: Long? = null
httpClient(parent.mountId).use { client ->
actor.httpClient(parent.mountId).use { client ->
for (attempt in 0..MAX_NAME_ATTEMPTS) {
val newName = displayNameToMemberName(displayName, attempt)
val newLocation = parentUrl.newBuilder()
@ -392,7 +338,7 @@ class DavDocumentsProvider: DocumentsProvider() {
isDirectory = createDirectory
))
notifyFolderChanged(parentDocumentId)
actor.notifyFolderChanged(parentDocumentId)
break
} catch (e: HttpException) {
e.throwForDocumentProvider(true)
@ -406,7 +352,7 @@ class DavDocumentsProvider: DocumentsProvider() {
override fun deleteDocument(documentId: String) {
Logger.log.fine("WebDAV removeDocument $documentId")
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
httpClient(doc.mountId).use { client ->
actor.httpClient(doc.mountId).use { client ->
val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db))
try {
dav.delete {
@ -415,7 +361,7 @@ class DavDocumentsProvider: DocumentsProvider() {
Logger.log.fine("Successfully removed")
documentDao.delete(doc)
notifyFolderChanged(doc.parentId)
actor.notifyFolderChanged(doc.parentId)
} catch (e: HttpException) {
e.throwForDocumentProvider()
}
@ -434,7 +380,7 @@ class DavDocumentsProvider: DocumentsProvider() {
.addPathSegment(doc.name)
.build()
httpClient(doc.mountId).use { client ->
actor.httpClient(doc.mountId).use { client ->
val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db))
try {
dav.move(newLocation, false) {
@ -444,8 +390,8 @@ class DavDocumentsProvider: DocumentsProvider() {
doc.parentId = dstParent.id
documentDao.update(doc)
notifyFolderChanged(sourceParentDocumentId)
notifyFolderChanged(targetParentDocumentId)
actor.notifyFolderChanged(sourceParentDocumentId)
actor.notifyFolderChanged(targetParentDocumentId)
} catch (e: HttpException) {
e.throwForDocumentProvider()
}
@ -458,7 +404,7 @@ class DavDocumentsProvider: DocumentsProvider() {
Logger.log.fine("WebDAV renameDocument $documentId $displayName")
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
val oldUrl = doc.toHttpUrl(db)
httpClient(doc.mountId).use { client ->
actor.httpClient(doc.mountId).use { client ->
for (attempt in 0..MAX_NAME_ATTEMPTS) {
val newName = displayNameToMemberName(displayName, attempt)
val newLocation = oldUrl.newBuilder()
@ -473,7 +419,7 @@ class DavDocumentsProvider: DocumentsProvider() {
doc.name = newName
documentDao.update(doc)
notifyFolderChanged(doc.parentId)
actor.notifyFolderChanged(doc.parentId)
return doc.id.toString()
} catch (e: HttpException) {
e.throwForDocumentProvider(true)
@ -505,7 +451,7 @@ class DavDocumentsProvider: DocumentsProvider() {
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
val url = doc.toHttpUrl(db)
val client = httpClient(doc.mountId)
val client = actor.httpClient(doc.mountId)
val modeFlags = ParcelFileDescriptor.parseMode(mode)
val readAccess = when (mode) {
@ -544,7 +490,7 @@ class DavDocumentsProvider: DocumentsProvider() {
documentDao.update(doc)
}
notifyFolderChanged(doc.parentId)
actor.notifyFolderChanged(doc.parentId)
}
if (readAccess)
@ -571,7 +517,7 @@ class DavDocumentsProvider: DocumentsProvider() {
val thumbFile = thumbnailCache.get(doc, sizeHint) {
// create thumbnail
val result = executor.submit(Callable<ByteArray> {
httpClient(doc.mountId).use { client ->
actor.httpClient(doc.mountId).use { client ->
val url = doc.toHttpUrl(db)
val dav = DavResource(client.okHttpClient, url)
var result: ByteArray? = null
@ -619,26 +565,122 @@ class DavDocumentsProvider: DocumentsProvider() {
}
// helpers
/**
* Acts on behalf of [DavDocumentsProvider].
*
* Encapsulates functionality to make it easily testable without generating lots of
* DocumentProviders during the tests.
*
* By containing the actual implementation logic of [DavDocumentsProvider], it adds a layer of separation
* to make the methods of [DavDocumentsProvider] more easily testable.
* [DavDocumentsProvider]s methods should do nothing more, but to call [DavDocumentsActor]s methods.
*/
class DavDocumentsActor(
private val context: Context,
private val db: AppDatabase,
private val cookieStore: MutableMap<Long, CookieJar>,
private val credentialsStore: CredentialsStore,
private val authority: String
) {
private val documentDao = db.webDavDocumentDao()
private fun httpClient(mountId: Long): HttpClient {
val builder = HttpClient.Builder(ourContext, loggerLevel = HttpLoggingInterceptor.Level.HEADERS)
.cookieStore(cookieStore.getOrPut(mountId) { MemoryCookieStore() })
/**
* Finds children of given parent [WebDavDocument]. After querying, it
* updates existing children, adds new ones or removes deleted ones.
*
* There must never be more than one running instance per [parent]!
*
* @param parent folder to search for children
*/
@WorkerThread
internal fun queryChildren(parent: WebDavDocument) {
val oldChildren = documentDao.getChildren(parent.id).associateBy { it.name }.toMutableMap() // "name" of file/folder must be unique
val newChildrenList = hashMapOf<String, WebDavDocument>()
credentialsStore.getCredentials(mountId)?.let { credentials ->
builder.addAuthentication(null, credentials, true)
httpClient(parent.mountId).use { client ->
val parentUrl = parent.toHttpUrl(db)
val folder = DavCollection(client.okHttpClient, parentUrl)
try {
folder.propfind(1, *DAV_FILE_FIELDS) { response, relation ->
Logger.log.fine("$relation $response")
val resource: WebDavDocument =
when (relation) {
Response.HrefRelation.SELF -> // it's about the parent
parent
Response.HrefRelation.MEMBER -> // it's about a member
WebDavDocument(mountId = parent.mountId, parentId = parent.id, name = response.hrefName())
else ->
// we didn't request this; ignore it
return@propfind
}
response[ResourceType::class.java]?.types?.let { types ->
resource.isDirectory = types.contains(ResourceType.COLLECTION)
}
resource.displayName = response[DisplayName::class.java]?.displayName
resource.mimeType = response[GetContentType::class.java]?.type
response[GetETag::class.java]?.let { getETag ->
if (!getETag.weak)
resource.eTag = resource.eTag
}
resource.lastModified = response[GetLastModified::class.java]?.lastModified
resource.size = response[GetContentLength::class.java]?.contentLength
val privs = response[CurrentUserPrivilegeSet::class.java]
resource.mayBind = privs?.mayBind
resource.mayUnbind = privs?.mayUnbind
resource.mayWriteContent = privs?.mayWriteContent
resource.quotaAvailable = response[QuotaAvailableBytes::class.java]?.quotaAvailableBytes
resource.quotaUsed = response[QuotaUsedBytes::class.java]?.quotaUsedBytes
if (resource == parent)
documentDao.update(resource)
else {
documentDao.insertOrUpdate(resource)
newChildrenList[resource.name] = resource
}
// remove resource from known child nodes, because not found on server
oldChildren.remove(resource.name)
}
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't query children", e)
}
}
// Delete child nodes which were not rediscovered (deleted serverside)
for ((_, oldChild) in oldChildren)
documentDao.delete(oldChild)
}
return builder.build()
}
private fun notifyFolderChanged(parentDocumentId: Long?) {
if (parentDocumentId != null)
ourContext.contentResolver.notifyChange(buildChildDocumentsUri(authority, parentDocumentId.toString()), null)
}
// helpers
internal fun httpClient(mountId: Long): HttpClient {
val builder = HttpClient.Builder(context, loggerLevel = HttpLoggingInterceptor.Level.HEADERS)
.cookieStore(cookieStore.getOrPut(mountId) { MemoryCookieStore() })
credentialsStore.getCredentials(mountId)?.let { credentials ->
builder.addAuthentication(null, credentials, true)
}
return builder.build()
}
internal fun notifyFolderChanged(parentDocumentId: Long?) {
if (parentDocumentId != null)
context.contentResolver.notifyChange(buildChildDocumentsUri(authority, parentDocumentId.toString()), null)
}
internal fun notifyFolderChanged(parentDocumentId: String) {
context.contentResolver.notifyChange(buildChildDocumentsUri(authority, parentDocumentId), null)
}
private fun notifyFolderChanged(parentDocumentId: String) {
ourContext.contentResolver.notifyChange(buildChildDocumentsUri(authority, parentDocumentId), null)
}