[WebDAV] Implement command pattern, streamline lifecycle, remove WebdavScope (#1617)

* Remove WebdavScope as it is no longer needed

* [WIP] DavDocumentsProviderImpl

* [WIP] Move DavDocumentsProvider to BaseDavDocumentsProvider and implementation to DavDocumentsProvider

* Adapt tests and DI

* [WIP] Implement Command pattern

* Finish Command pattern, add deprecation notices

* Unify DavDocumentsProvider with wrapper again

* Get rid of DavDocumentsActor

* Add notes about lifecycle, remove shutdown
This commit is contained in:
Ricki Hirner
2025-07-29 10:17:05 +02:00
committed by GitHub
parent e13c140554
commit 44b52f65a2
19 changed files with 1226 additions and 880 deletions

View File

@@ -2,7 +2,7 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav
package at.bitfire.davdroid.webdav.operation
import android.content.Context
import android.security.NetworkSecurityPolicy
@@ -14,6 +14,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.junit4.MockKRule
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
@@ -29,29 +30,7 @@ import java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class DavDocumentsProviderTest {
companion object {
private const val PATH_WEBDAV_ROOT = "/webdav"
}
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var credentialsStore: CredentialsStore
@Inject
lateinit var davDocumentsActorFactory: DavDocumentsProvider.DavDocumentsActor.Factory
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var testDispatcher: TestDispatcher
class QueryChildDocumentsOperationTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@@ -59,13 +38,32 @@ class DavDocumentsProviderTest {
@get:Rule
val mockkRule = MockKRule(this)
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var operation: QueryChildDocumentsOperation
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var testDispatcher: TestDispatcher
private lateinit var server: MockWebServer
private lateinit var client: HttpClient
private lateinit var mount: WebDavMount
private lateinit var rootDocument: WebDavDocument
@Before
fun setUp() {
hiltRule.inject()
// create server and client
server = MockWebServer().apply {
dispatcher = testDispatcher
start()
@@ -75,50 +73,49 @@ class DavDocumentsProviderTest {
// mock server delivers HTTP without encryption
assertTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
// create WebDAV mount and root document in DB
runBlocking {
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
mount = db.webDavMountDao().getById(mountId)
rootDocument = db.webDavDocumentDao().getOrCreateRoot(mount)
}
}
@After
fun tearDown() {
client.close()
server.shutdown()
runBlocking {
db.webDavMountDao().deleteAsync(mount)
}
}
@Test
fun testDoQueryChildren_insert() = runTest {
// Create parent and root in database
val id = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
val webDavMount = db.webDavMountDao().getById(id)
val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
// Query
val actor = davDocumentsActorFactory.create(
cookieStore = mutableMapOf(),
credentialsStore = credentialsStore
)
actor.queryChildren(parent)
operation.queryChildren(rootDocument)
// Assert new children were inserted into db
assertEquals(3, db.webDavDocumentDao().getChildren(parent.id).size)
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[0].displayName)
assertEquals("MeowMeow_Cats.docx", db.webDavDocumentDao().getChildren(parent.id)[1].displayName)
assertEquals("Secret_Document.pages", db.webDavDocumentDao().getChildren(parent.id)[2].displayName)
assertEquals(3, db.webDavDocumentDao().getChildren(rootDocument.id).size)
assertEquals("Library", db.webDavDocumentDao().getChildren(rootDocument.id)[0].displayName)
assertEquals("MeowMeow_Cats.docx", db.webDavDocumentDao().getChildren(rootDocument.id)[1].displayName)
assertEquals("Secret_Document.pages", db.webDavDocumentDao().getChildren(rootDocument.id)[2].displayName)
}
@Test
fun testDoQueryChildren_update() = runTest {
// Create parent and root in database
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
val webDavMount = db.webDavMountDao().getById(mountId)
val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
assertEquals("Cat food storage", db.webDavDocumentDao().get(parent.id)!!.displayName)
assertEquals("Cat food storage", db.webDavDocumentDao().get(rootDocument.id)!!.displayName)
// Create a folder
val folderId = db.webDavDocumentDao().insert(
WebDavDocument(
0,
mountId,
parent.id,
mount.id,
rootDocument.id,
"My_Books",
true,
"My Books",
@@ -128,38 +125,25 @@ class DavDocumentsProviderTest {
assertEquals("My Books", db.webDavDocumentDao().get(folderId)!!.displayName)
// Query - should update the parent displayname and folder name
val actor = davDocumentsActorFactory.create(
cookieStore = mutableMapOf(),
credentialsStore = credentialsStore
)
actor.queryChildren(parent)
operation.queryChildren(rootDocument)
// Assert parent and children were updated in database
assertEquals("Cats WebDAV", db.webDavDocumentDao().get(parent.id)!!.displayName)
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[0].name)
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[0].displayName)
assertEquals("Cats WebDAV", db.webDavDocumentDao().get(rootDocument.id)!!.displayName)
assertEquals("Library", db.webDavDocumentDao().getChildren(rootDocument.id)[0].name)
assertEquals("Library", db.webDavDocumentDao().getChildren(rootDocument.id)[0].displayName)
}
@Test
fun testDoQueryChildren_delete() = runTest {
// Create parent and root in database
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
val webDavMount = db.webDavMountDao().getById(mountId)
val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
// Create a folder
val folderId = db.webDavDocumentDao().insert(
WebDavDocument(0, mountId, parent.id, "deleteme", true, "Should be deleted")
WebDavDocument(0, mount.id, rootDocument.id, "deleteme", true, "Should be deleted")
)
assertEquals("deleteme", db.webDavDocumentDao().get(folderId)!!.name)
// Query - discovers serverside deletion
val actor = davDocumentsActorFactory.create(
cookieStore = mutableMapOf(),
credentialsStore = credentialsStore
)
actor.queryChildren(parent)
operation.queryChildren(rootDocument)
// Assert folder got deleted
assertEquals(null, db.webDavDocumentDao().get(folderId))
@@ -167,26 +151,17 @@ class DavDocumentsProviderTest {
@Test
fun testDoQueryChildren_updateTwoDirectoriesSimultaneously() = runTest {
// Create root in database
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
val webDavMount = db.webDavMountDao().getById(mountId)
val root = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
// Create two directories
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 parent1Id = db.webDavDocumentDao().insert(WebDavDocument(0, mount.id, rootDocument.id, "parent1", true))
val parent2Id = db.webDavDocumentDao().insert(WebDavDocument(0, mount.id, rootDocument.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
val actor = davDocumentsActorFactory.create(
cookieStore = mutableMapOf(),
credentialsStore = credentialsStore
)
actor.queryChildren(parent1)
actor.queryChildren(parent2)
operation.queryChildren(parent1)
operation.queryChildren(parent2)
// Assert the two folders names have changed
assertEquals("childOne.txt", db.webDavDocumentDao().getChildren(parent1Id)[0].name)
@@ -214,7 +189,7 @@ class DavDocumentsProviderTest {
PATH_WEBDAV_ROOT to arrayOf(
Resource("",
"<resourcetype><collection/></resourcetype>" +
"<displayname>Cats WebDAV</displayname>"
"<displayname>Cats WebDAV</displayname>"
),
Resource("Secret_Document.pages",
"<displayname>Secret_Document.pages</displayname>",
@@ -224,7 +199,7 @@ class DavDocumentsProviderTest {
),
Resource("Library",
"<resourcetype><collection/></resourcetype>" +
"<displayname>Library</displayname>"
"<displayname>Library</displayname>"
)
),
@@ -243,15 +218,15 @@ class DavDocumentsProviderTest {
val responses = propsMap[requestPath]?.joinToString { resource ->
"<response><href>$requestPath/${resource.name}</href><propstat><prop>" +
resource.props +
"</prop></propstat></response>"
"</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>"
"xmlns:CARD='urn:ietf:params:xml:ns:carddav' " +
"xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
responses +
"</multistatus>"
logger.info("Response: $multistatus")
return MockResponse()
@@ -264,4 +239,9 @@ class DavDocumentsProviderTest {
}
companion object {
private const val PATH_WEBDAV_ROOT = "/webdav"
}
}

View File

@@ -4,817 +4,103 @@
package at.bitfire.davdroid.webdav
import android.app.AuthenticationRequiredException
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.res.AssetFileDescriptor
import android.database.Cursor
import android.database.MatrixCursor
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Point
import android.media.ThumbnailUtils
import android.net.ConnectivityManager
import android.os.Build
import android.os.CancellationSignal
import android.os.ParcelFileDescriptor
import android.os.storage.StorageManager
import android.provider.DocumentsContract.Document
import android.provider.DocumentsContract.Root
import android.provider.DocumentsContract.buildChildDocumentsUri
import android.provider.DocumentsContract.buildRootsUri
import android.provider.DocumentsProvider
import android.webkit.MimeTypeMap
import androidx.core.app.TaskStackBuilder
import androidx.core.content.getSystemService
import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.GetContentLength
import at.bitfire.dav4jvm.property.webdav.GetContentType
import at.bitfire.dav4jvm.property.webdav.GetETag
import at.bitfire.dav4jvm.property.webdav.GetLastModified
import at.bitfire.dav4jvm.property.webdav.QuotaAvailableBytes
import at.bitfire.dav4jvm.property.webdav.QuotaUsedBytes
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.WebDavDocument
import at.bitfire.davdroid.db.WebDavDocumentDao
import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.network.MemoryCookieStore
import at.bitfire.davdroid.ui.webdav.WebdavMountsActivity
import at.bitfire.davdroid.webdav.cache.ThumbnailCache
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import at.bitfire.davdroid.webdav.operation.CopyDocumentOperation
import at.bitfire.davdroid.webdav.operation.CreateDocumentOperation
import at.bitfire.davdroid.webdav.operation.DeleteDocumentOperation
import at.bitfire.davdroid.webdav.operation.IsChildDocumentOperation
import at.bitfire.davdroid.webdav.operation.MoveDocumentOperation
import at.bitfire.davdroid.webdav.operation.OpenDocumentOperation
import at.bitfire.davdroid.webdav.operation.OpenDocumentThumbnailOperation
import at.bitfire.davdroid.webdav.operation.QueryChildDocumentsOperation
import at.bitfire.davdroid.webdav.operation.QueryDocumentOperation
import at.bitfire.davdroid.webdav.operation.QueryRootsOperation
import at.bitfire.davdroid.webdav.operation.RenameDocumentOperation
import dagger.hilt.EntryPoint
import dagger.hilt.EntryPoints
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withTimeout
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.logging.HttpLoggingInterceptor
import java.io.ByteArrayOutputStream
import java.io.FileNotFoundException
import java.net.HttpURLConnection
import java.util.concurrent.ConcurrentHashMap
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Provider
/**
* Provides functionality on WebDav documents.
*
* Actual implementation should go into [DavDocumentsActor].
* Hilt constructor injection can't be used for content providers because SingletonComponent
* may not ready yet when the content provider is created. So we use an explicit EntryPoint.
*
* Note: A DocumentsProvider is a ContentProvider and thus has no well-defined lifecycle. It
* is created by Android when it's first accessed and then stays in memory until the process
* is killed.
*/
class DavDocumentsProvider(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
): DocumentsProvider() {
class DavDocumentsProvider: DocumentsProvider() {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface DavDocumentsProviderEntryPoint {
fun appDatabase(): AppDatabase
fun davDocumentsActorFactory(): DavDocumentsActor.Factory
fun documentSortByMapper(): DocumentSortByMapper
fun logger(): Logger
fun randomAccessCallbackWrapperFactory(): RandomAccessCallbackWrapper.Factory
fun streamingFileDescriptorFactory(): StreamingFileDescriptor.Factory
fun webdavComponentBuilder(): WebdavComponentBuilder
fun copyDocumentOperation(): CopyDocumentOperation
fun createDocumentOperation(): CreateDocumentOperation
fun deleteDocumentOperation(): DeleteDocumentOperation
fun isChildDocumentOperation(): IsChildDocumentOperation
fun moveDocumentOperation(): MoveDocumentOperation
fun openDocumentOperation(): OpenDocumentOperation
fun openDocumentThumbnailOperation(): OpenDocumentThumbnailOperation
fun queryChildDocumentsOperation(): QueryChildDocumentsOperation
fun queryDocumentOperation(): QueryDocumentOperation
fun queryRootsOperation(): QueryRootsOperation
fun renameDocumentOperation(): RenameDocumentOperation
}
@EntryPoint
@InstallIn(WebdavComponent::class)
interface DavDocumentsProviderWebdavEntryPoint {
fun credentialsStore(): CredentialsStore
fun thumbnailCache(): ThumbnailCache
private val entryPoint: DavDocumentsProviderEntryPoint by lazy {
EntryPointAccessors.fromApplication<DavDocumentsProviderEntryPoint>(context!!)
}
companion object {
val DAV_FILE_FIELDS = arrayOf(
ResourceType.NAME,
CurrentUserPrivilegeSet.NAME,
DisplayName.NAME,
GetETag.NAME,
GetContentType.NAME,
GetContentLength.NAME,
GetLastModified.NAME,
QuotaAvailableBytes.NAME,
QuotaUsedBytes.NAME,
)
const val MAX_NAME_ATTEMPTS = 5
const val THUMBNAIL_TIMEOUT_MS = 15000L
fun notifyMountsChanged(context: Context) {
context.contentResolver.notifyChange(buildRootsUri(context.getString(R.string.webdav_authority)), null)
}
}
val documentProviderScope = CoroutineScope(SupervisorJob())
private val ourContext by lazy { context!! } // requireContext() requires API level 30
private val authority by lazy { ourContext.getString(R.string.webdav_authority) }
private val globalEntryPoint by lazy { EntryPointAccessors.fromApplication<DavDocumentsProviderEntryPoint>(ourContext) }
private val webdavEntryPoint by lazy {
EntryPoints.get(
globalEntryPoint.webdavComponentBuilder().build(),
DavDocumentsProviderWebdavEntryPoint::class.java
)
}
private val logger by lazy { globalEntryPoint.logger() }
private val db by lazy { globalEntryPoint.appDatabase() }
private val mountDao by lazy { db.webDavMountDao() }
private val documentDao by lazy { db.webDavDocumentDao() }
private val thumbnailCache by lazy { webdavEntryPoint.thumbnailCache() }
private val connectivityManager by lazy { ourContext.getSystemService<ConnectivityManager>()!! }
private val storageManager by lazy { ourContext.getSystemService<StorageManager>()!! }
/** 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 credentialsStore by lazy { webdavEntryPoint.credentialsStore() }
private val cookieStore by lazy { mutableMapOf<Long, CookieJar>() }
private val actor by lazy { globalEntryPoint.davDocumentsActorFactory().create(cookieStore, credentialsStore) }
override fun onCreate() = true
override fun shutdown() {
documentProviderScope.cancel()
}
/* Note: shutdown() is NOT called automatically by Android; a content provider lives until
the process is killed. */
/*** query ***/
override fun queryRoots(projection: Array<out String>?): Cursor {
logger.fine("WebDAV queryRoots")
val roots = MatrixCursor(projection ?: arrayOf(
Root.COLUMN_ROOT_ID,
Root.COLUMN_ICON,
Root.COLUMN_TITLE,
Root.COLUMN_FLAGS,
Root.COLUMN_DOCUMENT_ID,
Root.COLUMN_SUMMARY
))
override fun queryRoots(projection: Array<out String>?) =
entryPoint.queryRootsOperation().invoke(projection)
runBlocking {
for (mount in mountDao.getAll()) {
val rootDocument = documentDao.getOrCreateRoot(mount)
logger.info("Root ID: $rootDocument")
override fun queryDocument(documentId: String, projection: Array<out String>?) =
entryPoint.queryDocumentOperation().invoke(documentId, projection)
roots.newRow().apply {
add(Root.COLUMN_ROOT_ID, mount.id)
add(Root.COLUMN_ICON, R.mipmap.ic_launcher)
add(Root.COLUMN_TITLE, ourContext.getString(R.string.webdav_provider_root_title))
add(Root.COLUMN_DOCUMENT_ID, rootDocument.id.toString())
add(Root.COLUMN_SUMMARY, mount.name)
add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE or Root.FLAG_SUPPORTS_IS_CHILD)
override fun queryChildDocuments(parentDocumentId: String, projection: Array<out String>?, sortOrder: String?) =
entryPoint.queryChildDocumentsOperation().invoke(parentDocumentId, projection, sortOrder)
val quotaAvailable = rootDocument.quotaAvailable
if (quotaAvailable != null)
add(Root.COLUMN_AVAILABLE_BYTES, quotaAvailable)
val quotaUsed = rootDocument.quotaUsed
if (quotaAvailable != null && quotaUsed != null)
add(Root.COLUMN_CAPACITY_BYTES, quotaAvailable + quotaUsed)
}
}
}
return roots
}
override fun queryDocument(documentId: String, projection: Array<out String>?): Cursor {
logger.fine("WebDAV queryDocument $documentId ${projection?.joinToString("+")}")
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
val parent = doc.parentId?.let { parentId ->
documentDao.get(parentId)
}
return DocumentsCursor(projection ?: arrayOf(
Document.COLUMN_DOCUMENT_ID,
Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_MIME_TYPE,
Document.COLUMN_FLAGS,
Document.COLUMN_SIZE,
Document.COLUMN_LAST_MODIFIED,
Document.COLUMN_ICON,
Document.COLUMN_SUMMARY
)).apply {
val bundle = doc.toBundle(parent)
logger.fine("queryDocument($documentId) = $bundle")
// override display names of root documents
if (parent == null) {
val mount = runBlocking { mountDao.getById(doc.mountId) }
bundle.putString(Document.COLUMN_DISPLAY_NAME, mount.name)
}
addRow(bundle)
}
}
/**
* 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.fine("WebDAV queryChildDocuments $parentDocumentId $projection $sortOrder")
val parentId = parentDocumentId.toLong()
val parent = documentDao.get(parentId) ?: throw FileNotFoundException()
val columns = projection ?: arrayOf(
Document.COLUMN_DOCUMENT_ID,
Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_MIME_TYPE,
Document.COLUMN_FLAGS,
Document.COLUMN_SIZE,
Document.COLUMN_LAST_MODIFIED
)
// Register watcher
val result = DocumentsCursor(columns)
val notificationUri = buildChildDocumentsUri(authority, parentDocumentId)
result.setNotificationUri(ourContext.contentResolver, notificationUri)
// Dispatch worker querying for the children and keep track of it
val running = runningQueryChildren.getOrPut(parentId) {
documentProviderScope.launch {
actor.queryChildren(parent)
// Once the query is done, set query as finished (not running)
runningQueryChildren[parentId] = false
// .. and notify - effectively calling this method again
ourContext.contentResolver.notifyChange(notificationUri, null)
}
true
}
if (running) // worker still running
result.loading = true
else // remove worker from list if done
runningQueryChildren.remove(parentId)
// Prepare SORT BY clause
val mapper = globalEntryPoint.documentSortByMapper()
val sqlSortBy = if (sortOrder != null)
mapper.mapContentProviderToSql(sortOrder)
else
WebDavDocumentDao.DEFAULT_ORDER
// Regardless of whether the worker is done, return the children we already have
val children = documentDao.getChildren(parentId, sqlSortBy)
for (child in children) {
val bundle = child.toBundle(parent)
result.addRow(bundle)
}
return result
}
override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean {
logger.fine("WebDAV isChildDocument $parentDocumentId $documentId")
val parent = documentDao.get(parentDocumentId.toLong()) ?: throw FileNotFoundException()
var iter: WebDavDocument? = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
while (iter != null) {
val currentParentId = iter.parentId
if (currentParentId == parent.id)
return true
iter = if (currentParentId != null)
documentDao.get(currentParentId)
else
null
}
return false
}
override fun isChildDocument(parentDocumentId: String, documentId: String) =
entryPoint.isChildDocumentOperation().invoke(parentDocumentId, documentId)
/*** copy/create/delete/move/rename ***/
override fun copyDocument(sourceDocumentId: String, targetParentDocumentId: String): String = runBlocking {
logger.fine("WebDAV copyDocument $sourceDocumentId $targetParentDocumentId")
val srcDoc = documentDao.get(sourceDocumentId.toLong()) ?: throw FileNotFoundException()
val dstFolder = documentDao.get(targetParentDocumentId.toLong()) ?: throw FileNotFoundException()
val name = srcDoc.name
override fun copyDocument(sourceDocumentId: String, targetParentDocumentId: String) =
entryPoint.copyDocumentOperation().invoke(sourceDocumentId, targetParentDocumentId)
if (srcDoc.mountId != dstFolder.mountId)
throw UnsupportedOperationException("Can't COPY between WebDAV servers")
override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String? =
entryPoint.createDocumentOperation().invoke(parentDocumentId, mimeType, displayName)
actor.httpClient(srcDoc.mountId).use { client ->
val dav = DavResource(client.okHttpClient, srcDoc.toHttpUrl(db))
val dstUrl = dstFolder.toHttpUrl(db).newBuilder()
.addPathSegment(name)
.build()
override fun deleteDocument(documentId: String) =
entryPoint.deleteDocumentOperation().invoke(documentId)
try {
runInterruptible(ioDispatcher) {
dav.copy(dstUrl, false) {
// successfully copied
}
}
} catch (e: HttpException) {
e.throwForDocumentProvider()
}
override fun moveDocument(sourceDocumentId: String, sourceParentDocumentId: String, targetParentDocumentId: String) =
entryPoint.moveDocumentOperation().invoke(sourceDocumentId, sourceParentDocumentId, targetParentDocumentId)
val dstDocId = documentDao.insertOrReplace(
WebDavDocument(
mountId = dstFolder.mountId,
parentId = dstFolder.id,
name = name,
isDirectory = srcDoc.isDirectory,
displayName = srcDoc.displayName,
mimeType = srcDoc.mimeType,
size = srcDoc.size
)
).toString()
actor.notifyFolderChanged(targetParentDocumentId)
/* return */ dstDocId
}
}
override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String? = runBlocking {
logger.fine("WebDAV createDocument $parentDocumentId $mimeType $displayName")
val parent = documentDao.get(parentDocumentId.toLong()) ?: throw FileNotFoundException()
val createDirectory = mimeType == Document.MIME_TYPE_DIR
var docId: Long? = null
actor.httpClient(parent.mountId).use { client ->
for (attempt in 0..MAX_NAME_ATTEMPTS) {
val newName = displayNameToMemberName(displayName, attempt)
val parentUrl = parent.toHttpUrl(db)
val newLocation = parentUrl.newBuilder()
.addPathSegment(newName)
.build()
val doc = DavResource(client.okHttpClient, newLocation)
try {
runInterruptible(ioDispatcher) {
if (createDirectory)
doc.mkCol(null) {
// directory successfully created
}
else
doc.put("".toRequestBody(null), ifNoneMatch = true) {
// document successfully created
}
}
docId = documentDao.insertOrReplace(
WebDavDocument(
mountId = parent.mountId,
parentId = parent.id,
name = newName,
mimeType = mimeType.toMediaTypeOrNull(),
isDirectory = createDirectory
)
)
actor.notifyFolderChanged(parentDocumentId)
return@runBlocking docId.toString()
} catch (e: HttpException) {
e.throwForDocumentProvider(ignorePreconditionFailed = true)
}
}
}
null
}
override fun deleteDocument(documentId: String) = runBlocking {
logger.fine("WebDAV removeDocument $documentId")
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
actor.httpClient(doc.mountId).use { client ->
val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db))
try {
runInterruptible(ioDispatcher) {
dav.delete {
// successfully deleted
}
}
logger.fine("Successfully removed")
documentDao.delete(doc)
actor.notifyFolderChanged(doc.parentId)
} catch (e: HttpException) {
e.throwForDocumentProvider()
}
}
}
override fun moveDocument(sourceDocumentId: String, sourceParentDocumentId: String, targetParentDocumentId: String): String = runBlocking {
logger.fine("WebDAV moveDocument $sourceDocumentId $sourceParentDocumentId $targetParentDocumentId")
val doc = documentDao.get(sourceDocumentId.toLong()) ?: throw FileNotFoundException()
val dstParent = documentDao.get(targetParentDocumentId.toLong()) ?: throw FileNotFoundException()
if (doc.mountId != dstParent.mountId)
throw UnsupportedOperationException("Can't MOVE between WebDAV servers")
val newLocation = dstParent.toHttpUrl(db).newBuilder()
.addPathSegment(doc.name)
.build()
actor.httpClient(doc.mountId).use { client ->
val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db))
try {
runInterruptible(ioDispatcher) {
dav.move(newLocation, false) {
// successfully moved
}
}
documentDao.update(doc.copy(parentId = dstParent.id))
actor.notifyFolderChanged(sourceParentDocumentId)
actor.notifyFolderChanged(targetParentDocumentId)
} catch (e: HttpException) {
e.throwForDocumentProvider()
}
}
doc.id.toString()
}
override fun renameDocument(documentId: String, displayName: String): String? = runBlocking {
logger.fine("WebDAV renameDocument $documentId $displayName")
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
actor.httpClient(doc.mountId).use { client ->
for (attempt in 0..MAX_NAME_ATTEMPTS) {
val newName = displayNameToMemberName(displayName, attempt)
val oldUrl = doc.toHttpUrl(db)
val newLocation = oldUrl.newBuilder()
.removePathSegment(oldUrl.pathSegments.lastIndex)
.addPathSegment(newName)
.build()
try {
val dav = DavResource(client.okHttpClient, oldUrl)
runInterruptible(ioDispatcher) {
dav.move(newLocation, false) {
// successfully renamed
}
}
documentDao.update(doc.copy(name = newName))
actor.notifyFolderChanged(doc.parentId)
return@runBlocking doc.id.toString()
} catch (e: HttpException) {
e.throwForDocumentProvider(true)
}
}
}
null
}
private fun displayNameToMemberName(displayName: String, appendNumber: Int = 0): String {
val safeName = displayName.filterNot { it.isISOControl() }
if (appendNumber != 0) {
val extension: String? = MimeTypeMap.getFileExtensionFromUrl(displayName)
if (extension != null) {
val baseName = safeName.removeSuffix(".$extension")
return "${baseName}_$appendNumber.$extension"
} else
return "${safeName}_$appendNumber"
} else
return safeName
}
override fun renameDocument(documentId: String, displayName: String): String? =
entryPoint.renameDocumentOperation().invoke(documentId, displayName)
/*** read/write ***/
private suspend fun headRequest(client: HttpClient, url: HttpUrl): HeadResponse = runInterruptible(ioDispatcher) {
HeadResponse.fromUrl(client, url)
}
override fun openDocument(documentId: String, mode: String, signal: CancellationSignal?) =
entryPoint.openDocumentOperation().invoke(documentId, mode, signal)
override fun openDocument(documentId: String, mode: String, signal: CancellationSignal?): ParcelFileDescriptor = runBlocking {
logger.fine("WebDAV openDocument $documentId $mode $signal")
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
val url = doc.toHttpUrl(db)
val client = actor.httpClient(doc.mountId, logBody = false)
val modeFlags = ParcelFileDescriptor.parseMode(mode)
val readAccess = when (mode) {
"r" -> true
"w", "wt" -> false
else -> throw UnsupportedOperationException("Mode $mode not supported by WebDAV")
}
val accessScope = CoroutineScope(SupervisorJob())
signal?.setOnCancelListener {
logger.fine("Cancelling WebDAV access to $url")
accessScope.cancel()
}
val fileInfo = accessScope.async {
headRequest(client, url)
}.await()
logger.fine("Received file info: $fileInfo")
// RandomAccessCallback.Wrapper / StreamingFileDescriptor are responsible for closing httpClient
return@runBlocking if (
Build.VERSION.SDK_INT >= 26 && // openProxyFileDescriptor exists since Android 8.0
readAccess && // WebDAV doesn't support random write access natively
fileInfo.size != null && // file descriptor must return a useful value on getFileSize()
(fileInfo.eTag != null || fileInfo.lastModified != null) && // we need a method to determine whether the document has changed during access
fileInfo.supportsPartial == true // WebDAV server must support random access
) {
logger.fine("Creating RandomAccessCallback for $url")
val factory = globalEntryPoint.randomAccessCallbackWrapperFactory()
val accessor = factory.create(client, url, doc.mimeType, fileInfo, accessScope)
storageManager.openProxyFileDescriptor(modeFlags, accessor, accessor.workerHandler)
} else {
logger.fine("Creating StreamingFileDescriptor for $url")
val factory = globalEntryPoint.streamingFileDescriptorFactory()
val fd = factory.create(client, url, doc.mimeType, accessScope) { transferred ->
// called when transfer is finished
val now = System.currentTimeMillis()
if (!readAccess /* write access */) {
// write access, update file size
documentDao.update(doc.copy(size = transferred, lastModified = now))
}
actor.notifyFolderChanged(doc.parentId)
}
if (readAccess)
fd.download()
else
fd.upload()
}
}
override fun openDocumentThumbnail(documentId: String, sizeHint: Point, signal: CancellationSignal?): AssetFileDescriptor? {
logger.info("openDocumentThumbnail documentId=$documentId sizeHint=$sizeHint signal=$signal")
if (connectivityManager.isActiveNetworkMetered)
// don't download the large images just to create a thumbnail on metered networks
return null
if (signal == null) {
logger.warning("openDocumentThumbnail without cancellationSignal causes too much problems, please fix calling app")
return null
}
val accessScope = CoroutineScope(SupervisorJob())
signal.setOnCancelListener {
logger.fine("Cancelling thumbnail generation for $documentId")
accessScope.cancel()
}
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
val docCacheKey = doc.cacheKey()
if (docCacheKey == null) {
logger.warning("openDocumentThumbnail won't generate thumbnails when document state (ETag/Last-Modified) is unknown")
return null
}
val thumbFile = thumbnailCache.get(docCacheKey, sizeHint) {
// create thumbnail
val job = accessScope.async {
withTimeout(THUMBNAIL_TIMEOUT_MS) {
actor.httpClient(doc.mountId, logBody = false).use { client ->
val url = doc.toHttpUrl(db)
val dav = DavResource(client.okHttpClient, url)
var result: ByteArray? = null
runInterruptible(ioDispatcher) {
dav.get("image/*", null) { response ->
response.body.byteStream().use { data ->
BitmapFactory.decodeStream(data)?.let { bitmap ->
val thumb = ThumbnailUtils.extractThumbnail(bitmap, sizeHint.x, sizeHint.y)
val baos = ByteArrayOutputStream()
thumb.compress(Bitmap.CompressFormat.JPEG, 95, baos)
result = baos.toByteArray()
}
}
}
}
result
}
}
}
try {
runBlocking {
job.await()
}
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't generate thumbnail", e)
null
}
}
if (thumbFile != null)
return AssetFileDescriptor(
ParcelFileDescriptor.open(thumbFile, ParcelFileDescriptor.MODE_READ_ONLY),
0, thumbFile.length()
)
return null
}
/**
* 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 @AssistedInject constructor(
@Assisted private val cookieStores: MutableMap<Long, CookieJar>,
@Assisted private val credentialsStore: CredentialsStore,
@ApplicationContext private val context: Context,
private val db: AppDatabase,
private val httpClientBuilder: Provider<HttpClient.Builder>,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val logger: Logger
) {
@AssistedFactory
interface Factory {
fun create(cookieStore: MutableMap<Long, CookieJar>, credentialsStore: CredentialsStore): DavDocumentsActor
}
private val authority = context.getString(R.string.webdav_authority)
private val documentDao = db.webDavDocumentDao()
/**
* 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
*/
internal suspend 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>()
val parentUrl = parent.toHttpUrl(db)
httpClient(parent.mountId).use { client ->
val folder = DavCollection(client.okHttpClient, parentUrl)
try {
runInterruptible(ioDispatcher) {
folder.propfind(1, *DAV_FILE_FIELDS) { response, relation ->
logger.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; log a warning and ignore it
logger.warning("Ignoring unexpected $response $relation in $parentUrl")
return@propfind
}
}
val updatedResource = resource.copy(
isDirectory = response[ResourceType::class.java]?.types?.contains(ResourceType.COLLECTION)
?: resource.isDirectory,
displayName = response[DisplayName::class.java]?.displayName,
mimeType = response[GetContentType::class.java]?.type,
eTag = response[GetETag::class.java]?.takeIf { !it.weak }?.let { resource.eTag },
lastModified = response[GetLastModified::class.java]?.lastModified?.toEpochMilli(),
size = response[GetContentLength::class.java]?.contentLength,
mayBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind,
mayUnbind = response[CurrentUserPrivilegeSet::class.java]?.mayUnbind,
mayWriteContent = response[CurrentUserPrivilegeSet::class.java]?.mayWriteContent,
quotaAvailable = response[QuotaAvailableBytes::class.java]?.quotaAvailableBytes,
quotaUsed = response[QuotaUsedBytes::class.java]?.quotaUsedBytes,
)
if (resource == parent)
documentDao.update(updatedResource)
else {
documentDao.insertOrUpdate(updatedResource)
newChildrenList[resource.name] = updatedResource
}
// remove resource from known child nodes, because not found on server
oldChildren.remove(resource.name)
}
}
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't query children", e)
}
}
// Delete child nodes which were not rediscovered (deleted serverside)
for ((_, oldChild) in oldChildren)
documentDao.delete(oldChild)
}
// helpers
/**
* Creates a HTTP client that can be used to access resources in the given mount.
*
* @param mountId ID of the mount to access
* @param logBody whether to log the body of HTTP requests (disable for potentially large files)
*/
internal fun httpClient(mountId: Long, logBody: Boolean = true): HttpClient {
val builder = httpClientBuilder.get()
.loggerInterceptorLevel(if (logBody) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.HEADERS)
.setCookieStore(
cookieStores.getOrPut(mountId) { MemoryCookieStore() }
)
credentialsStore.getCredentials(mountId)?.let { credentials ->
builder.authenticate(host = null, getCredentials = { credentials })
}
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 HttpException.throwForDocumentProvider(ignorePreconditionFailed: Boolean = false) {
when (code) {
HttpURLConnection.HTTP_UNAUTHORIZED -> {
if (Build.VERSION.SDK_INT >= 26) {
// TODO edit mount
val intent = Intent(ourContext, WebdavMountsActivity::class.java)
throw AuthenticationRequiredException(
this,
TaskStackBuilder.create(ourContext)
.addNextIntentWithParentStack(intent)
.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
)
}
}
HttpURLConnection.HTTP_NOT_FOUND ->
throw FileNotFoundException()
HttpURLConnection.HTTP_PRECON_FAILED ->
if (ignorePreconditionFailed)
return
}
// re-throw
throw this
}
override fun openDocumentThumbnail(documentId: String, sizeHint: Point, signal: CancellationSignal?) =
entryPoint.openDocumentThumbnailOperation().invoke(documentId, sizeHint, signal)
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.network.MemoryCookieStore
import okhttp3.CookieJar
import okhttp3.logging.HttpLoggingInterceptor
import javax.inject.Inject
import javax.inject.Provider
class DavHttpClientBuilder @Inject constructor(
private val credentialsStore: CredentialsStore,
private val httpClientBuilder: Provider<HttpClient.Builder>,
) {
/**
* Creates an HTTP client that can be used to access resources in the given mount.
*
* @param mountId ID of the mount to access
* @param logBody whether to log the body of HTTP requests (disable for potentially large files)
*/
fun build(mountId: Long, logBody: Boolean = true): HttpClient {
val cookieStore = cookieStores.getOrPut(mountId) {
MemoryCookieStore()
}
val builder = httpClientBuilder.get()
.loggerInterceptorLevel(if (logBody) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.HEADERS)
.setCookieStore(cookieStore)
credentialsStore.getCredentials(mountId)?.let { credentials ->
builder.authenticate(host = null, getCredentials = { credentials })
}
return builder.build()
}
companion object {
/** in-memory cookie stores (one per mount ID) that are available until the content
* provider (= process) is terminated */
private val cookieStores = mutableMapOf<Long, CookieJar>()
}
}

View File

@@ -0,0 +1,91 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav
import android.app.AuthenticationRequiredException
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.DocumentsContract.buildChildDocumentsUri
import android.provider.DocumentsContract.buildRootsUri
import android.webkit.MimeTypeMap
import androidx.core.app.TaskStackBuilder
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.webdav.WebdavMountsActivity
import java.io.FileNotFoundException
import java.net.HttpURLConnection
object DocumentProviderUtils {
const val MAX_DISPLAYNAME_TO_MEMBERNAME_ATTEMPTS = 5
internal fun displayNameToMemberName(displayName: String, appendNumber: Int = 0): String {
val safeName = displayName.filterNot { it.isISOControl() }
if (appendNumber != 0) {
val extension: String? = MimeTypeMap.getFileExtensionFromUrl(displayName)
if (extension != null) {
val baseName = safeName.removeSuffix(".$extension")
return "${baseName}_$appendNumber.$extension"
} else
return "${safeName}_$appendNumber"
} else
return safeName
}
internal fun notifyFolderChanged(context: Context, parentDocumentId: Long?) {
if (parentDocumentId != null)
context.contentResolver.notifyChange(
buildChildDocumentsUri(
context.getString(R.string.webdav_authority),
parentDocumentId.toString()
),
null
)
}
internal fun notifyFolderChanged(context: Context, parentDocumentId: String) {
context.contentResolver.notifyChange(
buildChildDocumentsUri(
context.getString(R.string.webdav_authority),
parentDocumentId
),
null
)
}
internal fun notifyMountsChanged(context: Context) {
context.contentResolver.notifyChange(
buildRootsUri(context.getString(R.string.webdav_authority)),
null)
}
}
internal fun HttpException.throwForDocumentProvider(context: Context, ignorePreconditionFailed: Boolean = false) {
when (code) {
HttpURLConnection.HTTP_UNAUTHORIZED -> {
if (Build.VERSION.SDK_INT >= 26) {
val intent = Intent(context, WebdavMountsActivity::class.java)
throw AuthenticationRequiredException(
this,
TaskStackBuilder.create(context)
.addNextIntentWithParentStack(intent)
.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
)
}
}
HttpURLConnection.HTTP_NOT_FOUND ->
throw FileNotFoundException()
HttpURLConnection.HTTP_PRECON_FAILED ->
if (ignorePreconditionFailed)
return
}
// re-throw
throw this
}

View File

@@ -4,7 +4,6 @@
package at.bitfire.davdroid.webdav
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.os.ProxyFileDescriptorCallback
@@ -49,7 +48,7 @@ import kotlin.concurrent.schedule
*
* @param httpClient HTTP client [RandomAccessCallbackWrapper] is responsible to close it
*/
@RequiresApi(Build.VERSION_CODES.O)
@RequiresApi(26)
class RandomAccessCallbackWrapper @AssistedInject constructor(
@Assisted private val httpClient: HttpClient,
@Assisted private val url: HttpUrl,

View File

@@ -66,7 +66,7 @@ class WebDavMountRepository @Inject constructor(
credentialsStore.setCredentials(id, credentials)
// notify content URI listeners
DavDocumentsProvider.notifyMountsChanged(context)
DocumentProviderUtils.notifyMountsChanged(context)
return true
}
@@ -79,7 +79,7 @@ class WebDavMountRepository @Inject constructor(
CredentialsStore(context).setCredentials(mount.id, null)
// notify content URI listeners
DavDocumentsProvider.notifyMountsChanged(context)
DocumentProviderUtils.notifyMountsChanged(context)
}
fun getAllFlow() = mountDao.getAllFlow()

View File

@@ -1,22 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav
import dagger.hilt.DefineComponent
import dagger.hilt.components.SingletonComponent
import javax.inject.Scope
@Scope
@Retention(AnnotationRetention.BINARY)
annotation class WebdavScoped
@WebdavScoped
@DefineComponent(parent = SingletonComponent::class)
interface WebdavComponent
@DefineComponent.Builder
interface WebdavComponentBuilder {
fun build(): WebdavComponent
}

View File

@@ -11,7 +11,6 @@ import android.os.storage.StorageManager
import android.text.format.Formatter
import androidx.core.content.getSystemService
import at.bitfire.davdroid.db.WebDavDocument
import at.bitfire.davdroid.webdav.WebdavScoped
import com.google.common.hash.Hashing
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
@@ -21,7 +20,6 @@ import javax.inject.Inject
/**
* Simple disk cache for image thumbnails.
*/
@WebdavScoped
class ThumbnailCache @Inject constructor(
@ApplicationContext context: Context,
logger: Logger

View File

@@ -0,0 +1,77 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav.operation
import android.content.Context
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.WebDavDocument
import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
import at.bitfire.davdroid.webdav.DocumentProviderUtils
import at.bitfire.davdroid.webdav.throwForDocumentProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.runInterruptible
import java.io.FileNotFoundException
import java.util.logging.Logger
import javax.inject.Inject
class CopyDocumentOperation @Inject constructor(
@ApplicationContext private val context: Context,
private val db: AppDatabase,
private val httpClientBuilder: DavHttpClientBuilder,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val logger: Logger
) {
private val documentDao = db.webDavDocumentDao()
operator fun invoke(sourceDocumentId: String, targetParentDocumentId: String): String = runBlocking {
logger.fine("WebDAV copyDocument $sourceDocumentId $targetParentDocumentId")
val srcDoc = documentDao.get(sourceDocumentId.toLong()) ?: throw FileNotFoundException()
val dstFolder = documentDao.get(targetParentDocumentId.toLong()) ?: throw FileNotFoundException()
val name = srcDoc.name
if (srcDoc.mountId != dstFolder.mountId)
throw UnsupportedOperationException("Can't COPY between WebDAV servers")
httpClientBuilder.build(srcDoc.mountId).use { client ->
val dav = DavResource(client.okHttpClient, srcDoc.toHttpUrl(db))
val dstUrl = dstFolder.toHttpUrl(db).newBuilder()
.addPathSegment(name)
.build()
try {
runInterruptible(ioDispatcher) {
dav.copy(dstUrl, false) {
// successfully copied
}
}
} catch (e: HttpException) {
e.throwForDocumentProvider(context)
}
val dstDocId = documentDao.insertOrReplace(
WebDavDocument(
mountId = dstFolder.mountId,
parentId = dstFolder.id,
name = name,
isDirectory = srcDoc.isDirectory,
displayName = srcDoc.displayName,
mimeType = srcDoc.mimeType,
size = srcDoc.size
)
).toString()
DocumentProviderUtils.notifyFolderChanged(context, targetParentDocumentId)
/* return */ dstDocId
}
}
}

View File

@@ -0,0 +1,86 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav.operation
import android.content.Context
import android.provider.DocumentsContract.Document
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.WebDavDocument
import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
import at.bitfire.davdroid.webdav.DocumentProviderUtils
import at.bitfire.davdroid.webdav.DocumentProviderUtils.displayNameToMemberName
import at.bitfire.davdroid.webdav.throwForDocumentProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.runInterruptible
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.FileNotFoundException
import java.util.logging.Logger
import javax.inject.Inject
class CreateDocumentOperation @Inject constructor(
@ApplicationContext private val context: Context,
private val db: AppDatabase,
private val httpClientBuilder: DavHttpClientBuilder,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val logger: Logger
) {
private val documentDao = db.webDavDocumentDao()
operator fun invoke(parentDocumentId: String, mimeType: String, displayName: String): String? = runBlocking {
logger.fine("WebDAV createDocument $parentDocumentId $mimeType $displayName")
val parent = documentDao.get(parentDocumentId.toLong()) ?: throw FileNotFoundException()
val createDirectory = mimeType == Document.MIME_TYPE_DIR
var docId: Long?
httpClientBuilder.build(parent.mountId).use { client ->
for (attempt in 0..DocumentProviderUtils.MAX_DISPLAYNAME_TO_MEMBERNAME_ATTEMPTS) {
val newName = displayNameToMemberName(displayName, attempt)
val parentUrl = parent.toHttpUrl(db)
val newLocation = parentUrl.newBuilder()
.addPathSegment(newName)
.build()
val doc = DavResource(client.okHttpClient, newLocation)
try {
runInterruptible(ioDispatcher) {
if (createDirectory)
doc.mkCol(null) {
// directory successfully created
}
else
doc.put("".toRequestBody(null), ifNoneMatch = true) {
// document successfully created
}
}
docId = documentDao.insertOrReplace(
WebDavDocument(
mountId = parent.mountId,
parentId = parent.id,
name = newName,
mimeType = mimeType.toMediaTypeOrNull(),
isDirectory = createDirectory
)
)
DocumentProviderUtils.notifyFolderChanged(context, parentDocumentId)
return@runBlocking docId.toString()
} catch (e: HttpException) {
e.throwForDocumentProvider(context, ignorePreconditionFailed = true)
}
}
}
null
}
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav.operation
import android.content.Context
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
import at.bitfire.davdroid.webdav.DocumentProviderUtils
import at.bitfire.davdroid.webdav.throwForDocumentProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.runInterruptible
import java.io.FileNotFoundException
import java.util.logging.Logger
import javax.inject.Inject
class DeleteDocumentOperation @Inject constructor(
@ApplicationContext private val context: Context,
private val db: AppDatabase,
private val httpClientBuilder: DavHttpClientBuilder,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val logger: Logger
) {
private val documentDao = db.webDavDocumentDao()
operator fun invoke(documentId: String) = runBlocking {
logger.fine("WebDAV removeDocument $documentId")
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
httpClientBuilder.build(doc.mountId).use { client ->
val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db))
try {
runInterruptible(ioDispatcher) {
dav.delete {
// successfully deleted
}
}
logger.fine("Successfully removed")
documentDao.delete(doc)
DocumentProviderUtils.notifyFolderChanged(context, doc.parentId)
} catch (e: HttpException) {
e.throwForDocumentProvider(context)
}
}
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav.operation
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.WebDavDocument
import java.io.FileNotFoundException
import java.util.logging.Logger
import javax.inject.Inject
class IsChildDocumentOperation @Inject constructor(
db: AppDatabase,
private val logger: Logger
) {
private val documentDao = db.webDavDocumentDao()
operator fun invoke(parentDocumentId: String, documentId: String): Boolean {
logger.fine("WebDAV isChildDocument $parentDocumentId $documentId")
val parent = documentDao.get(parentDocumentId.toLong()) ?: throw FileNotFoundException()
var iter: WebDavDocument? = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
while (iter != null) {
val currentParentId = iter.parentId
if (currentParentId == parent.id)
return true
iter = if (currentParentId != null)
documentDao.get(currentParentId)
else
null
}
return false
}
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav.operation
import android.content.Context
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
import at.bitfire.davdroid.webdav.DocumentProviderUtils
import at.bitfire.davdroid.webdav.throwForDocumentProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.runInterruptible
import java.io.FileNotFoundException
import java.util.logging.Logger
import javax.inject.Inject
class MoveDocumentOperation @Inject constructor(
@ApplicationContext private val context: Context,
private val db: AppDatabase,
private val httpClientBuilder: DavHttpClientBuilder,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val logger: Logger
) {
private val documentDao = db.webDavDocumentDao()
operator fun invoke(sourceDocumentId: String, sourceParentDocumentId: String, targetParentDocumentId: String): String = runBlocking {
logger.fine("WebDAV moveDocument $sourceDocumentId $sourceParentDocumentId $targetParentDocumentId")
val doc = documentDao.get(sourceDocumentId.toLong()) ?: throw FileNotFoundException()
val dstParent = documentDao.get(targetParentDocumentId.toLong()) ?: throw FileNotFoundException()
if (doc.mountId != dstParent.mountId)
throw UnsupportedOperationException("Can't MOVE between WebDAV servers")
val newLocation = dstParent.toHttpUrl(db).newBuilder()
.addPathSegment(doc.name)
.build()
httpClientBuilder.build(doc.mountId).use { client ->
val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db))
try {
runInterruptible(ioDispatcher) {
dav.move(newLocation, false) {
// successfully moved
}
}
documentDao.update(doc.copy(parentId = dstParent.id))
DocumentProviderUtils.notifyFolderChanged(context, sourceParentDocumentId)
DocumentProviderUtils.notifyFolderChanged(context, targetParentDocumentId)
} catch (e: HttpException) {
e.throwForDocumentProvider(context)
}
}
doc.id.toString()
}
}

View File

@@ -0,0 +1,116 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav.operation
import android.content.Context
import android.os.Build
import android.os.CancellationSignal
import android.os.ParcelFileDescriptor
import android.os.storage.StorageManager
import androidx.core.content.getSystemService
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
import at.bitfire.davdroid.webdav.DocumentProviderUtils
import at.bitfire.davdroid.webdav.HeadResponse
import at.bitfire.davdroid.webdav.RandomAccessCallbackWrapper
import at.bitfire.davdroid.webdav.StreamingFileDescriptor
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.runInterruptible
import okhttp3.HttpUrl
import java.io.FileNotFoundException
import java.util.logging.Logger
import javax.inject.Inject
class OpenDocumentOperation @Inject constructor(
@ApplicationContext private val context: Context,
private val db: AppDatabase,
private val httpClientBuilder: DavHttpClientBuilder,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val logger: Logger,
private val randomAccessCallbackWrapperFactory: RandomAccessCallbackWrapper.Factory,
private val streamingFileDescriptorFactory: StreamingFileDescriptor.Factory
) {
private val documentDao = db.webDavDocumentDao()
private val storageManager = context.getSystemService<StorageManager>()!!
operator fun invoke(documentId: String, mode: String, signal: CancellationSignal?): ParcelFileDescriptor = runBlocking {
logger.fine("WebDAV openDocument $documentId $mode $signal")
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
val url = doc.toHttpUrl(db)
val client = httpClientBuilder.build(doc.mountId, logBody = false)
val modeFlags = ParcelFileDescriptor.parseMode(mode)
val readAccess = when (mode) {
"r" -> true
"w", "wt" -> false
else -> throw UnsupportedOperationException("Mode $mode not supported by WebDAV")
}
val accessScope = CoroutineScope(SupervisorJob())
signal?.setOnCancelListener {
logger.fine("Cancelling WebDAV access to $url")
accessScope.cancel()
}
val fileInfo = accessScope.async {
headRequest(client, url)
}.await()
logger.fine("Received file info: $fileInfo")
// RandomAccessCallback.Wrapper / StreamingFileDescriptor are responsible for closing httpClient
return@runBlocking if (
androidSupportsRandomAccess &&
readAccess && // WebDAV doesn't support random write access (natively)
fileInfo.size != null && // file descriptor must return a useful value on getFileSize()
(fileInfo.eTag != null || fileInfo.lastModified != null) && // we need a method to determine whether the document has changed during access
fileInfo.supportsPartial == true // WebDAV server must support random access
) {
logger.fine("Creating RandomAccessCallback for $url")
val accessor = randomAccessCallbackWrapperFactory.create(client, url, doc.mimeType, fileInfo, accessScope)
storageManager.openProxyFileDescriptor(modeFlags, accessor, accessor.workerHandler)
} else {
logger.fine("Creating StreamingFileDescriptor for $url")
val fd = streamingFileDescriptorFactory.create(client, url, doc.mimeType, accessScope) { transferred ->
// called when transfer is finished
val now = System.currentTimeMillis()
if (!readAccess /* write access */) {
// write access, update file size
documentDao.update(doc.copy(size = transferred, lastModified = now))
}
DocumentProviderUtils.notifyFolderChanged(context, doc.parentId)
}
if (readAccess)
fd.download()
else
fd.upload()
}
}
private suspend fun headRequest(client: HttpClient, url: HttpUrl): HeadResponse = runInterruptible(ioDispatcher) {
HeadResponse.fromUrl(client, url)
}
companion object {
// openProxyFileDescriptor exists since Android 8.0
val androidSupportsRandomAccess = Build.VERSION.SDK_INT >= 26
}
}

View File

@@ -0,0 +1,126 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav.operation
import android.content.Context
import android.content.res.AssetFileDescriptor
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Point
import android.media.ThumbnailUtils
import android.net.ConnectivityManager
import android.os.CancellationSignal
import android.os.ParcelFileDescriptor
import androidx.core.content.getSystemService
import at.bitfire.dav4jvm.DavResource
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
import at.bitfire.davdroid.webdav.cache.ThumbnailCache
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withTimeout
import java.io.ByteArrayOutputStream
import java.io.FileNotFoundException
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
import kotlin.use
class OpenDocumentThumbnailOperation @Inject constructor(
@ApplicationContext private val context: Context,
private val db: AppDatabase,
private val httpClientBuilder: DavHttpClientBuilder,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val logger: Logger,
private val thumbnailCache: ThumbnailCache
) {
private val documentDao = db.webDavDocumentDao()
operator fun invoke(documentId: String, sizeHint: Point, signal: CancellationSignal?): AssetFileDescriptor? {
logger.info("openDocumentThumbnail documentId=$documentId sizeHint=$sizeHint signal=$signal")
// don't download the large images just to create a thumbnail on metered networks
val connectivityManager = context.getSystemService<ConnectivityManager>()!!
if (connectivityManager.isActiveNetworkMetered)
return null
if (signal == null) {
logger.warning("openDocumentThumbnail without cancellationSignal causes too much problems, please fix calling app")
return null
}
val accessScope = CoroutineScope(SupervisorJob())
signal.setOnCancelListener {
logger.fine("Cancelling thumbnail generation for $documentId")
accessScope.cancel()
}
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
val docCacheKey = doc.cacheKey()
if (docCacheKey == null) {
logger.warning("openDocumentThumbnail won't generate thumbnails when document state (ETag/Last-Modified) is unknown")
return null
}
val thumbFile = thumbnailCache.get(docCacheKey, sizeHint) {
// create thumbnail
val job = accessScope.async {
withTimeout(THUMBNAIL_TIMEOUT_MS) {
httpClientBuilder.build(doc.mountId, logBody = false).use { client ->
val url = doc.toHttpUrl(db)
val dav = DavResource(client.okHttpClient, url)
var result: ByteArray? = null
runInterruptible(ioDispatcher) {
dav.get("image/*", null) { response ->
response.body.byteStream().use { data ->
BitmapFactory.decodeStream(data)?.let { bitmap ->
val thumb = ThumbnailUtils.extractThumbnail(bitmap, sizeHint.x, sizeHint.y)
val baos = ByteArrayOutputStream()
thumb.compress(Bitmap.CompressFormat.JPEG, 95, baos)
result = baos.toByteArray()
}
}
}
}
result
}
}
}
try {
runBlocking {
job.await()
}
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't generate thumbnail", e)
null
}
}
if (thumbFile != null)
return AssetFileDescriptor(
ParcelFileDescriptor.open(thumbFile, ParcelFileDescriptor.MODE_READ_ONLY),
0, thumbFile.length()
)
return null
}
companion object {
const val THUMBNAIL_TIMEOUT_MS = 15000L
}
}

View File

@@ -0,0 +1,214 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav.operation
import android.content.Context
import android.provider.DocumentsContract.Document
import android.provider.DocumentsContract.buildChildDocumentsUri
import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.GetContentLength
import at.bitfire.dav4jvm.property.webdav.GetContentType
import at.bitfire.dav4jvm.property.webdav.GetETag
import at.bitfire.dav4jvm.property.webdav.GetLastModified
import at.bitfire.dav4jvm.property.webdav.QuotaAvailableBytes
import at.bitfire.dav4jvm.property.webdav.QuotaUsedBytes
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.WebDavDocument
import at.bitfire.davdroid.db.WebDavDocumentDao
import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
import at.bitfire.davdroid.webdav.DocumentSortByMapper
import at.bitfire.davdroid.webdav.DocumentsCursor
import dagger.Lazy
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import java.io.FileNotFoundException
import java.util.concurrent.ConcurrentHashMap
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
class QueryChildDocumentsOperation @Inject constructor(
@ApplicationContext private val context: Context,
private val db: AppDatabase,
private val documentSortByMapper: Lazy<DocumentSortByMapper>,
private val httpClientBuilder: DavHttpClientBuilder,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val logger: Logger
) {
private val authority = context.getString(R.string.webdav_authority)
private val documentDao = db.webDavDocumentDao()
private val backgroundScope = CoroutineScope(SupervisorJob())
operator fun invoke(parentDocumentId: String, projection: Array<out String>?, sortOrder: String?) =
synchronized(QueryChildDocumentsOperation::class.java) {
queryChildDocuments(parentDocumentId, projection, sortOrder)
}
private fun queryChildDocuments(
parentDocumentId: String,
projection: Array<out String>?,
sortOrder: String?
): DocumentsCursor {
logger.fine("WebDAV queryChildDocuments $parentDocumentId $projection $sortOrder")
val parentId = parentDocumentId.toLong()
val parent = documentDao.get(parentId) ?: throw FileNotFoundException()
val columns = projection ?: arrayOf(
Document.COLUMN_DOCUMENT_ID,
Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_MIME_TYPE,
Document.COLUMN_FLAGS,
Document.COLUMN_SIZE,
Document.COLUMN_LAST_MODIFIED
)
// Register watcher
val result = DocumentsCursor(columns)
val notificationUri = buildChildDocumentsUri(authority, parentDocumentId)
result.setNotificationUri(context.contentResolver, notificationUri)
// Dispatch worker querying for the children and keep track of it
val running = runningQueryChildren.getOrPut(parentId) {
backgroundScope.launch {
queryChildren(parent)
// Once the query is done, set query as finished (not running)
runningQueryChildren[parentId] = false
// .. and notify - effectively calling this method again
context.contentResolver.notifyChange(notificationUri, null)
}
true
}
if (running) // worker still running
result.loading = true
else // remove worker from list if done
runningQueryChildren.remove(parentId)
// Prepare SORT BY clause
val mapper = documentSortByMapper.get()
val sqlSortBy = if (sortOrder != null)
mapper.mapContentProviderToSql(sortOrder)
else
WebDavDocumentDao.DEFAULT_ORDER
// Regardless of whether the worker is done, return the children we already have
val children = documentDao.getChildren(parentId, sqlSortBy)
for (child in children) {
val bundle = child.toBundle(parent)
result.addRow(bundle)
}
return result
}
/**
* 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
*/
internal suspend 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>()
val parentUrl = parent.toHttpUrl(db)
httpClientBuilder.build(parent.mountId).use { client ->
val folder = DavCollection(client.okHttpClient, parentUrl)
try {
runInterruptible(ioDispatcher) {
folder.propfind(1, *DAV_FILE_FIELDS) { response, relation ->
logger.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; log a warning and ignore it
logger.warning("Ignoring unexpected $response $relation in $parentUrl")
return@propfind
}
}
val updatedResource = resource.copy(
isDirectory = response[ResourceType::class.java]?.types?.contains(ResourceType.COLLECTION)
?: resource.isDirectory,
displayName = response[DisplayName::class.java]?.displayName,
mimeType = response[GetContentType::class.java]?.type,
eTag = response[GetETag::class.java]?.takeIf { !it.weak }?.let { resource.eTag },
lastModified = response[GetLastModified::class.java]?.lastModified?.toEpochMilli(),
size = response[GetContentLength::class.java]?.contentLength,
mayBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind,
mayUnbind = response[CurrentUserPrivilegeSet::class.java]?.mayUnbind,
mayWriteContent = response[CurrentUserPrivilegeSet::class.java]?.mayWriteContent,
quotaAvailable = response[QuotaAvailableBytes::class.java]?.quotaAvailableBytes,
quotaUsed = response[QuotaUsedBytes::class.java]?.quotaUsedBytes,
)
if (resource == parent)
documentDao.update(updatedResource)
else {
documentDao.insertOrUpdate(updatedResource)
newChildrenList[resource.name] = updatedResource
}
// remove resource from known child nodes, because not found on server
oldChildren.remove(resource.name)
}
}
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't query children", e)
}
}
// Delete child nodes which were not rediscovered (deleted serverside)
for ((_, oldChild) in oldChildren)
documentDao.delete(oldChild)
}
companion object {
val DAV_FILE_FIELDS = arrayOf(
ResourceType.NAME,
CurrentUserPrivilegeSet.NAME,
DisplayName.NAME,
GetETag.NAME,
GetContentType.NAME,
GetContentLength.NAME,
GetLastModified.NAME,
QuotaAvailableBytes.NAME,
QuotaUsedBytes.NAME,
)
/** 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>()
}
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav.operation
import android.database.Cursor
import android.provider.DocumentsContract.Document
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.webdav.DocumentsCursor
import kotlinx.coroutines.runBlocking
import java.io.FileNotFoundException
import java.util.logging.Logger
import javax.inject.Inject
class QueryDocumentOperation @Inject constructor(
db: AppDatabase,
private val logger: Logger
) {
private val documentDao = db.webDavDocumentDao()
private val mountDao = db.webDavMountDao()
operator fun invoke(documentId: String, projection: Array<out String>?): Cursor {
logger.fine("WebDAV queryDocument $documentId ${projection?.joinToString("+")}")
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
val parent = doc.parentId?.let { parentId ->
documentDao.get(parentId)
}
return DocumentsCursor(projection ?: arrayOf(
Document.COLUMN_DOCUMENT_ID,
Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_MIME_TYPE,
Document.COLUMN_FLAGS,
Document.COLUMN_SIZE,
Document.COLUMN_LAST_MODIFIED,
Document.COLUMN_ICON,
Document.COLUMN_SUMMARY
)).apply {
val bundle = doc.toBundle(parent)
logger.fine("queryDocument($documentId) = $bundle")
// override display names of root documents
if (parent == null) {
val mount = runBlocking { mountDao.getById(doc.mountId) }
bundle.putString(Document.COLUMN_DISPLAY_NAME, mount.name)
}
addRow(bundle)
}
}
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav.operation
import android.content.Context
import android.database.Cursor
import android.database.MatrixCursor
import android.provider.DocumentsContract.Root
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.runBlocking
import java.util.logging.Logger
import javax.inject.Inject
class QueryRootsOperation @Inject constructor(
@ApplicationContext private val context: Context,
db: AppDatabase,
private val logger: Logger
) {
private val documentDao = db.webDavDocumentDao()
private val mountDao = db.webDavMountDao()
operator fun invoke(projection: Array<out String>?): Cursor {
logger.fine("WebDAV queryRoots")
val roots = MatrixCursor(projection ?: arrayOf(
Root.COLUMN_ROOT_ID,
Root.COLUMN_ICON,
Root.COLUMN_TITLE,
Root.COLUMN_FLAGS,
Root.COLUMN_DOCUMENT_ID,
Root.COLUMN_SUMMARY
))
runBlocking {
for (mount in mountDao.getAll()) {
val rootDocument = documentDao.getOrCreateRoot(mount)
logger.info("Root ID: $rootDocument")
roots.newRow().apply {
add(Root.COLUMN_ROOT_ID, mount.id)
add(Root.COLUMN_ICON, R.mipmap.ic_launcher)
add(Root.COLUMN_TITLE, context.getString(R.string.webdav_provider_root_title))
add(Root.COLUMN_DOCUMENT_ID, rootDocument.id.toString())
add(Root.COLUMN_SUMMARY, mount.name)
add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE or Root.FLAG_SUPPORTS_IS_CHILD)
val quotaAvailable = rootDocument.quotaAvailable
if (quotaAvailable != null)
add(Root.COLUMN_AVAILABLE_BYTES, quotaAvailable)
val quotaUsed = rootDocument.quotaUsed
if (quotaAvailable != null && quotaUsed != null)
add(Root.COLUMN_CAPACITY_BYTES, quotaAvailable + quotaUsed)
}
}
}
return roots
}
}

View File

@@ -0,0 +1,67 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav.operation
import android.content.Context
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
import at.bitfire.davdroid.webdav.DocumentProviderUtils
import at.bitfire.davdroid.webdav.DocumentProviderUtils.displayNameToMemberName
import at.bitfire.davdroid.webdav.throwForDocumentProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.runInterruptible
import java.io.FileNotFoundException
import java.util.logging.Logger
import javax.inject.Inject
class RenameDocumentOperation @Inject constructor(
@ApplicationContext private val context: Context,
private val db: AppDatabase,
private val httpClientBuilder: DavHttpClientBuilder,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val logger: Logger
) {
private val documentDao = db.webDavDocumentDao()
operator fun invoke(documentId: String, displayName: String): String? = runBlocking {
logger.fine("WebDAV renameDocument $documentId $displayName")
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
httpClientBuilder.build(doc.mountId).use { client ->
for (attempt in 0..DocumentProviderUtils.MAX_DISPLAYNAME_TO_MEMBERNAME_ATTEMPTS) {
val newName = displayNameToMemberName(displayName, attempt)
val oldUrl = doc.toHttpUrl(db)
val newLocation = oldUrl.newBuilder()
.removePathSegment(oldUrl.pathSegments.lastIndex)
.addPathSegment(newName)
.build()
try {
val dav = DavResource(client.okHttpClient, oldUrl)
runInterruptible(ioDispatcher) {
dav.move(newLocation, false) {
// successfully renamed
}
}
documentDao.update(doc.copy(name = newName))
DocumentProviderUtils.notifyFolderChanged(context, doc.parentId)
return@runBlocking doc.id.toString()
} catch (e: HttpException) {
e.throwForDocumentProvider(context, true)
}
}
}
null
}
}