Compare commits

...

5 Commits

Author SHA1 Message Date
Ricki Hirner
722edc1ea7 Add logging to DocumentProviderUtils
- Introduce a logger instance
- Log URI when notifying folder changes
2026-01-23 12:07:21 +01:00
Ricki Hirner
7b68ec0832 - Pass ioDispatcher to runBlocking in WebDAV operations
- Refactor timeout configuration in HttpClientBuilder for reusability
2026-01-23 11:52:02 +01:00
Ricki Hirner
530e62f1f4 Refactor URLBuilder usage
- Update URLBuilder usage in RenameDocumentOperation.kt
- Update URLBuilder usage in CopyDocumentOperation.kt
- Update URLBuilder usage in MoveDocumentOperation.kt
2026-01-23 10:59:18 +01:00
Ricki Hirner
f0a8694436 Rewrite CopyDocumentOperation.kt to Ktor 2026-01-23 10:56:12 +01:00
Ricki Hirner
dd839f5b96 [WebDAV] Refactor RenameDocumentOperation to Ktor
- Update imports to use Ktor-based classes
- Refactor `RenameDocumentOperation` to use Ktor HTTP client
- Add support for both HttpException types in `throwForDocumentProvider`
2026-01-23 10:49:48 +01:00
5 changed files with 114 additions and 85 deletions

View File

@@ -69,6 +69,7 @@ class HttpClientBuilder @Inject constructor(
) {
companion object {
init {
// make sure Conscrypt is available when the HttpClientBuilder class is loaded the first time
ConscryptIntegration().initialize()
@@ -84,12 +85,18 @@ class HttpClientBuilder @Inject constructor(
* The shared client is available for the lifetime of the application and must not be shut down or
* closed (which is not necessary, according to its documentation).
*/
val sharedOkHttpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.pingInterval(45, TimeUnit.SECONDS) // avoid cancellation because of missing traffic; only works for HTTP/2
.build()
val sharedOkHttpClient = OkHttpClient.Builder().apply {
configureTimeouts(this)
}.build()
private fun configureTimeouts(okBuilder: OkHttpClient.Builder) {
okBuilder
.connectTimeout(15, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.pingInterval(45, TimeUnit.SECONDS) // avoid cancellation because of missing traffic; only works for HTTP/2
}
}
/**
@@ -407,6 +414,11 @@ class HttpClientBuilder @Inject constructor(
config {
// OkHttpClient.Builder configuration here
// we don't use the sharedOkHttpClient, so we have to apply timeouts again
configureTimeouts(this)
// build most config on okhttp level
configureOkHttp(this)
}
}

View File

@@ -13,15 +13,19 @@ import android.provider.DocumentsContract.buildChildDocumentsUri
import android.provider.DocumentsContract.buildRootsUri
import android.webkit.MimeTypeMap
import androidx.core.app.TaskStackBuilder
import at.bitfire.dav4jvm.okhttp.exception.HttpException
import at.bitfire.dav4jvm.ktor.exception.HttpException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.webdav.WebdavMountsActivity
import java.io.FileNotFoundException
import java.util.logging.Logger
object DocumentProviderUtils {
object DocumentProviderUtils {
const val MAX_DISPLAYNAME_TO_MEMBERNAME_ATTEMPTS = 5
private val logger
get() = Logger.getLogger(javaClass.name)
internal fun displayNameToMemberName(displayName: String, appendNumber: Int = 0): String {
val safeName = displayName.filterNot { it.isISOControl() }
@@ -37,24 +41,23 @@ object DocumentProviderUtils {
}
internal fun notifyFolderChanged(context: Context, parentDocumentId: Long?) {
if (parentDocumentId != null)
context.contentResolver.notifyChange(
buildChildDocumentsUri(
context.getString(R.string.webdav_authority),
parentDocumentId.toString()
),
null
if (parentDocumentId != null) {
val uri = buildChildDocumentsUri(
context.getString(R.string.webdav_authority),
parentDocumentId.toString()
)
logger.fine("Notifying observers of $uri")
context.contentResolver.notifyChange(uri, null)
}
}
internal fun notifyFolderChanged(context: Context, parentDocumentId: String) {
context.contentResolver.notifyChange(
buildChildDocumentsUri(
context.getString(R.string.webdav_authority),
parentDocumentId
),
null
val uri = buildChildDocumentsUri(
context.getString(R.string.webdav_authority),
parentDocumentId
)
logger.fine("Notifying observers of $uri")
context.contentResolver.notifyChange(uri, null)
}
internal fun notifyMountsChanged(context: Context) {
@@ -66,12 +69,20 @@ object DocumentProviderUtils {
}
internal fun HttpException.throwForDocumentProvider(context: Context, ignorePreconditionFailed: Boolean = false) {
throwForDocumentProvider(context, statusCode, this, ignorePreconditionFailed)
}
internal fun at.bitfire.dav4jvm.okhttp.exception.HttpException.throwForDocumentProvider(context: Context, ignorePreconditionFailed: Boolean = false) {
throwForDocumentProvider(context, statusCode, this, ignorePreconditionFailed)
}
private fun throwForDocumentProvider(context: Context, statusCode: Int, ex: Exception, ignorePreconditionFailed: Boolean) {
when (statusCode) {
401 -> {
if (Build.VERSION.SDK_INT >= 26) {
val intent = Intent(context, WebdavMountsActivity::class.java)
throw AuthenticationRequiredException(
this,
ex,
TaskStackBuilder.create(context)
.addNextIntentWithParentStack(intent)
.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
@@ -86,5 +97,5 @@ internal fun HttpException.throwForDocumentProvider(context: Context, ignorePrec
}
// re-throw
throw this
throw ex
}

View File

@@ -5,8 +5,8 @@
package at.bitfire.davdroid.webdav.operation
import android.content.Context
import at.bitfire.dav4jvm.okhttp.DavResource
import at.bitfire.dav4jvm.okhttp.exception.HttpException
import at.bitfire.dav4jvm.ktor.DavResource
import at.bitfire.dav4jvm.ktor.exception.HttpException
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.WebDavDocument
import at.bitfire.davdroid.di.IoDispatcher
@@ -14,9 +14,10 @@ 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 io.ktor.http.URLBuilder
import io.ktor.http.appendPathSegments
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
@@ -31,7 +32,7 @@ class CopyDocumentOperation @Inject constructor(
private val documentDao = db.webDavDocumentDao()
operator fun invoke(sourceDocumentId: String, targetParentDocumentId: String): String = runBlocking {
operator fun invoke(sourceDocumentId: String, targetParentDocumentId: String): String = runBlocking(ioDispatcher) {
logger.fine("WebDAV copyDocument $sourceDocumentId $targetParentDocumentId")
val srcDoc = documentDao.get(sourceDocumentId.toLong()) ?: throw FileNotFoundException()
val dstFolder = documentDao.get(targetParentDocumentId.toLong()) ?: throw FileNotFoundException()
@@ -40,21 +41,22 @@ class CopyDocumentOperation @Inject constructor(
if (srcDoc.mountId != dstFolder.mountId)
throw UnsupportedOperationException("Can't COPY between WebDAV servers")
val client = httpClientBuilder.build(srcDoc.mountId)
val dav = DavResource(client, srcDoc.toHttpUrl(db))
val dstUrl = dstFolder.toHttpUrl(db).newBuilder()
.addPathSegment(name)
.build()
httpClientBuilder
.buildKtor(srcDoc.mountId)
.use { httpClient ->
val dav = DavResource(httpClient, srcDoc.toKtorUrl(db))
val dstUrl = URLBuilder(dstFolder.toKtorUrl(db))
.appendPathSegments(name)
.build()
try {
runInterruptible(ioDispatcher) {
dav.copy(dstUrl, false) {
// successfully copied
try {
dav.copy(dstUrl, false) {
// successfully copied
}
} catch (e: HttpException) {
e.throwForDocumentProvider(context)
}
}
} catch (e: HttpException) {
e.throwForDocumentProvider(context)
}
val dstDocId = documentDao.insertOrReplace(
WebDavDocument(

View File

@@ -5,17 +5,18 @@
package at.bitfire.davdroid.webdav.operation
import android.content.Context
import at.bitfire.dav4jvm.okhttp.DavResource
import at.bitfire.dav4jvm.okhttp.exception.HttpException
import at.bitfire.dav4jvm.ktor.DavResource
import at.bitfire.dav4jvm.ktor.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 io.ktor.http.URLBuilder
import io.ktor.http.appendPathSegments
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
@@ -30,7 +31,7 @@ class MoveDocumentOperation @Inject constructor(
private val documentDao = db.webDavDocumentDao()
operator fun invoke(sourceDocumentId: String, sourceParentDocumentId: String, targetParentDocumentId: String): String = runBlocking {
operator fun invoke(sourceDocumentId: String, sourceParentDocumentId: String, targetParentDocumentId: String): String = runBlocking(ioDispatcher) {
logger.fine("WebDAV moveDocument $sourceDocumentId $sourceParentDocumentId $targetParentDocumentId")
val doc = documentDao.get(sourceDocumentId.toLong()) ?: throw FileNotFoundException()
val dstParent = documentDao.get(targetParentDocumentId.toLong()) ?: throw FileNotFoundException()
@@ -38,27 +39,28 @@ class MoveDocumentOperation @Inject constructor(
if (doc.mountId != dstParent.mountId)
throw UnsupportedOperationException("Can't MOVE between WebDAV servers")
val newLocation = dstParent.toHttpUrl(db).newBuilder()
.addPathSegment(doc.name)
.build()
httpClientBuilder
.buildKtor(doc.mountId)
.use { httpClient ->
val newLocation = URLBuilder(dstParent.toKtorUrl(db))
.appendPathSegments(doc.name)
.build()
val client = httpClientBuilder.build(doc.mountId)
val dav = DavResource(client, doc.toHttpUrl(db))
try {
runInterruptible(ioDispatcher) {
dav.move(newLocation, false) {
// successfully moved
val dav = DavResource(httpClient, doc.toKtorUrl(db))
try {
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)
}
}
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

@@ -5,8 +5,8 @@
package at.bitfire.davdroid.webdav.operation
import android.content.Context
import at.bitfire.dav4jvm.okhttp.DavResource
import at.bitfire.dav4jvm.okhttp.exception.HttpException
import at.bitfire.dav4jvm.ktor.DavResource
import at.bitfire.dav4jvm.ktor.exception.HttpException
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
@@ -14,9 +14,9 @@ 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 io.ktor.http.URLBuilder
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
@@ -31,34 +31,36 @@ class RenameDocumentOperation @Inject constructor(
private val documentDao = db.webDavDocumentDao()
operator fun invoke(documentId: String, displayName: String): String? = runBlocking {
operator fun invoke(documentId: String, displayName: String): String? = runBlocking(ioDispatcher) {
logger.fine("WebDAV renameDocument $documentId $displayName")
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
val client = httpClientBuilder.build(doc.mountId)
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, oldUrl)
runInterruptible(ioDispatcher) {
dav.move(newLocation, false) {
// successfully renamed
httpClientBuilder
.buildKtor(doc.mountId)
.use { httpClient ->
for (attempt in 0..DocumentProviderUtils.MAX_DISPLAYNAME_TO_MEMBERNAME_ATTEMPTS) {
val newName = displayNameToMemberName(displayName, attempt)
val oldUrl = doc.toKtorUrl(db)
val newLocation = URLBuilder(oldUrl)
.apply {
// Remove the last path segment (current file name) and add the new name
pathSegments = pathSegments.dropLast(1) + newName
}.build()
try {
val dav = DavResource(httpClient, oldUrl)
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)
}
}
documentDao.update(doc.copy(name = newName))
DocumentProviderUtils.notifyFolderChanged(context, doc.parentId)
return@runBlocking doc.id.toString()
} catch (e: HttpException) {
e.throwForDocumentProvider(context, true)
}
}
null
}