Compare commits

...

7 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
Ricki Hirner
2c7b36ecd5 Use Ktor for Push registration (#1930)
* Replace OkHttp with Ktor for push notifications

* Use Ktor HttpHeaders for Location and Expires
2026-01-23 10:27:16 +01:00
Ricki Hirner
cf80b11808 [WebDAV] Rewrite OpenDocumentThumbnailOperation to Ktor (#1931)
* Add Ktor HTTP client support

- Introduce `buildKtor` method in `DavHttpClientBuilder` for creating Ktor HTTP clients.
- Update `OpenDocumentThumbnailOperation` to use Ktor for downloading and creating thumbnails.

* Refactor HttpClientBuilder creation

- Extract common logic into `createBuilder` method
- Update `build` and `buildKtor` methods to use `createBuilder`

* Refactor OpenDocumentThumbnailOperation

- Remove unnecessary `withContext` call
- Use `HttpHeaders.Accept` and `ContentType.Image.Any` for HTTP header
- Simplify the function structure

* Refactor thumbnail generation

- Remove redundant `accessScope`
- Simplify and encapsulate thumbnail creation logic
- Ensure proper cancellation handling

* Update OpenDocumentThumbnailOperation logging

- Enhance cancellation log message with document ID
- Improve URL conversion warning message

* Update WebDAV operations and document handling

- Add `@MustBeClosed` annotation to `buildKtor` method in `DavHttpClientBuilder`
- Remove unnecessary imports and update URL conversion in `OpenDocumentThumbnailOperation`
- Add `toKtorUrl` method in `WebDavDocument` for URL conversion

* Use streaming bitmap decoding

* Add comments to OpenDocumentThumbnailOperation for future improvements

---------

Co-authored-by: Arnau Mora <arnyminerz@proton.me>
2026-01-22 17:05:32 +01:00
9 changed files with 261 additions and 184 deletions

View File

@@ -13,8 +13,10 @@ import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import at.bitfire.dav4jvm.HttpUtils.toKtorUrl
import at.bitfire.davdroid.util.DavUtils.MEDIA_TYPE_OCTET_STREAM
import at.bitfire.davdroid.webdav.DocumentState
import io.ktor.http.Url
import okhttp3.HttpUrl
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
@@ -128,6 +130,9 @@ data class WebDavDocument(
return builder.build()
}
suspend fun toKtorUrl(db: AppDatabase): Url =
toHttpUrl(db).toKtorUrl()
/**
* Represents a WebDAV document in a given state (with a given ETag/Last-Modified).

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

@@ -12,11 +12,13 @@ import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import at.bitfire.dav4jvm.HttpUtils
import at.bitfire.dav4jvm.HttpUtils.toKtorUrl
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.XmlUtils.insertTag
import at.bitfire.dav4jvm.okhttp.DavCollection
import at.bitfire.dav4jvm.okhttp.DavResource
import at.bitfire.dav4jvm.okhttp.exception.DavException
import at.bitfire.dav4jvm.ktor.DavCollection
import at.bitfire.dav4jvm.ktor.DavResource
import at.bitfire.dav4jvm.ktor.exception.DavException
import at.bitfire.dav4jvm.ktor.toUrlOrNull
import at.bitfire.dav4jvm.property.push.WebDAVPush
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
@@ -29,15 +31,14 @@ import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.sync.account.InvalidAccountException
import dagger.Lazy
import dagger.hilt.android.qualifiers.ApplicationContext
import io.ktor.client.HttpClient
import io.ktor.http.HttpHeaders
import io.ktor.http.Url
import io.ktor.http.isSuccess
import io.ktor.utils.io.ByteReadChannel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import org.unifiedpush.android.connector.UnifiedPush
import org.unifiedpush.android.connector.data.PushEndpoint
import java.io.StringWriter
@@ -176,24 +177,26 @@ class PushRegistrationManager @Inject constructor(
return
val account = accountRepository.get().fromName(service.accountName)
val httpClient = httpClientBuilder.get()
httpClientBuilder.get()
.fromAccountAsync(account)
.build()
for (collection in subscribeTo)
try {
val expires = collection.pushSubscriptionExpires
// calculate next run time, but use the duplicate interval for safety (times are not exact)
val nextRun = Instant.now() + Duration.ofDays(2 * WORKER_INTERVAL_DAYS)
if (expires != null && expires >= nextRun.epochSecond)
logger.fine("Push subscription for ${collection.url} is still valid until ${collection.pushSubscriptionExpires}")
else {
// no existing subscription or expiring soon
logger.fine("Registering push subscription for ${collection.url}")
subscribe(httpClient, collection, endpoint)
.buildKtor()
.use { httpClient ->
for (collection in subscribeTo)
try {
val expires = collection.pushSubscriptionExpires
// calculate next run time, but use the duplicate interval for safety (times are not exact)
val nextRun = Instant.now() + Duration.ofDays(2 * WORKER_INTERVAL_DAYS)
if (expires != null && expires >= nextRun.epochSecond)
logger.fine("Push subscription for ${collection.url} is still valid until ${collection.pushSubscriptionExpires}")
else {
// no existing subscription or expiring soon
logger.fine("Registering push subscription for ${collection.url}")
subscribe(httpClient, collection, endpoint)
}
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't register subscription at CalDAV/CardDAV server", e)
}
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't register subscription at CalDAV/CardDAV server", e)
}
}
}
/**
@@ -224,7 +227,7 @@ class PushRegistrationManager @Inject constructor(
* @param collection collection to subscribe to
* @param endpoint subscription to register
*/
private suspend fun subscribe(httpClient: OkHttpClient, collection: Collection, endpoint: PushEndpoint) {
private suspend fun subscribe(httpClient: HttpClient, collection: Collection, endpoint: PushEndpoint) {
// requested expiration time: 3 days
val requestedExpiration = Instant.now() + Duration.ofDays(3)
@@ -257,26 +260,24 @@ class PushRegistrationManager @Inject constructor(
}
serializer.endDocument()
runInterruptible(ioDispatcher) {
val xml = writer.toString().toRequestBody(DavResource.MIME_XML)
DavCollection(httpClient, collection.url).post(xml) { response ->
if (response.isSuccessful) {
// update subscription URL and expiration in DB
val subscriptionUrl = response.header("Location")
val expires = response.header("Expires")?.let { expiresDate ->
HttpUtils.parseDate(expiresDate)
} ?: requestedExpiration
DavCollection(httpClient, collection.url.toKtorUrl()).post(
{ ByteReadChannel(writer.toString()) },
DavResource.MIME_XML_UTF8
) { response ->
if (response.status.isSuccess()) {
// update subscription URL and expiration in DB
val subscriptionUrl = response.headers[HttpHeaders.Location]
val expires = response.headers[HttpHeaders.Expires]?.let { expiresDate ->
HttpUtils.parseDate(expiresDate)
} ?: requestedExpiration
runBlocking {
collectionRepository.updatePushSubscription(
id = collection.id,
subscriptionUrl = subscriptionUrl,
expires = expires?.epochSecond
)
}
} else
logger.warning("Couldn't register push for ${collection.url}: $response")
}
collectionRepository.updatePushSubscription(
id = collection.id,
subscriptionUrl = subscriptionUrl,
expires = expires?.epochSecond
)
} else
logger.warning("Couldn't register push for ${collection.url}: $response")
}
}
@@ -288,22 +289,22 @@ class PushRegistrationManager @Inject constructor(
return
val account = accountRepository.get().fromName(service.accountName)
val httpClient = httpClientBuilder.get()
httpClientBuilder.get()
.fromAccountAsync(account)
.build()
for (collection in from)
collection.pushSubscription?.toHttpUrlOrNull()?.let { url ->
logger.info("Unsubscribing Push from ${collection.url}")
unsubscribe(httpClient, collection, url)
}
.buildKtor()
.use { httpClient ->
for (collection in from)
collection.pushSubscription?.toUrlOrNull()?.let { url ->
logger.info("Unsubscribing Push from ${collection.url}")
unsubscribe(httpClient, collection, url)
}
}
}
private suspend fun unsubscribe(httpClient: OkHttpClient, collection: Collection, url: HttpUrl) {
private suspend fun unsubscribe(httpClient: HttpClient, collection: Collection, url: Url) {
try {
runInterruptible(ioDispatcher) {
DavResource(httpClient, url).delete {
// deleted
}
DavResource(httpClient, url).delete {
// deleted
}
} catch (e: DavException) {
logger.log(Level.WARNING, "Couldn't unregister push for ${collection.url}", e)

View File

@@ -6,6 +6,8 @@ package at.bitfire.davdroid.webdav
import at.bitfire.davdroid.network.HttpClientBuilder
import at.bitfire.davdroid.network.MemoryCookieStore
import com.google.errorprone.annotations.MustBeClosed
import io.ktor.client.HttpClient
import okhttp3.CookieJar
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
@@ -24,6 +26,31 @@ class DavHttpClientBuilder @Inject constructor(
* @param logBody whether to log the body of HTTP requests (disable for potentially large files)
*/
fun build(mountId: Long, logBody: Boolean = true): OkHttpClient {
val builder = createBuilder(mountId, logBody)
return builder.build()
}
/**
* Creates a Ktor 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)
* @return the new HttpClient which **must be closed by the caller**
*/
@MustBeClosed
fun buildKtor(mountId: Long, logBody: Boolean = true): HttpClient {
val builder = createBuilder(mountId, logBody)
return builder.buildKtor()
}
/**
* Creates and configures an HttpClientBuilder with authentication and cookie store.
*
* @param mountId ID of the mount to access
* @param logBody whether to log the body of HTTP requests (disable for potentially large files)
* @return configured HttpClientBuilder ready for building
*/
private fun createBuilder(mountId: Long, logBody: Boolean = true): HttpClientBuilder {
val cookieStore = cookieStores.getOrPut(mountId) {
MemoryCookieStore()
}
@@ -38,7 +65,7 @@ class DavHttpClientBuilder @Inject constructor(
)
}
return builder.build()
return builder
}

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

@@ -14,19 +14,22 @@ import android.net.ConnectivityManager
import android.os.CancellationSignal
import android.os.ParcelFileDescriptor
import androidx.core.content.getSystemService
import at.bitfire.dav4jvm.okhttp.DavResource
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.cache.ThumbnailCache
import dagger.hilt.android.qualifiers.ApplicationContext
import io.ktor.client.request.header
import io.ktor.client.request.prepareGet
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.isSuccess
import io.ktor.utils.io.jvm.javaio.toInputStream
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
@@ -58,11 +61,6 @@ class OpenDocumentThumbnailOperation @Inject constructor(
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()
@@ -73,37 +71,7 @@ class OpenDocumentThumbnailOperation @Inject constructor(
}
val thumbFile = thumbnailCache.get(docCacheKey, sizeHint) {
// create thumbnail
val job = accessScope.async {
withTimeout(THUMBNAIL_TIMEOUT_MS) {
val client = httpClientBuilder.build(doc.mountId, logBody = false)
val url = doc.toHttpUrl(db)
val dav = DavResource(client, 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
}
createThumbnail(doc, sizeHint, signal)
}
if (thumbFile != null)
@@ -115,6 +83,53 @@ class OpenDocumentThumbnailOperation @Inject constructor(
return null
}
private fun createThumbnail(doc: WebDavDocument, sizeHint: Point, signal: CancellationSignal): ByteArray? =
try {
runBlocking(ioDispatcher) {
signal.setOnCancelListener {
logger.fine("Cancelling thumbnail generation for #${doc.id}")
cancel() // cancel current coroutine scope
}
withTimeout(THUMBNAIL_TIMEOUT_MS) {
downloadAndCreateThumbnail(doc, db, sizeHint)
}
}
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't generate thumbnail", e)
null
}
private suspend fun downloadAndCreateThumbnail(doc: WebDavDocument, db: AppDatabase, sizeHint: Point): ByteArray? =
httpClientBuilder
.buildKtor(doc.mountId, logBody = false)
.use { httpClient ->
val url = doc.toKtorUrl(db)
try {
httpClient.prepareGet(url) {
header(HttpHeaders.Accept, ContentType.Image.Any.toString())
}.execute { response ->
if (response.status.isSuccess()) {
val imageStream = response.bodyAsChannel().toInputStream()
BitmapFactory.decodeStream(imageStream)?.let { bitmap ->
/* Now the whole decoded input bitmap is in memory. This could be improved in the future:
1. By writing the input bitmap to a temporary file, and extracting the thumbnail from that file.
2. By using a dedicated image loading library it could be possible to only extract potential
embedded thumbnails and thus save network traffic. */
val thumb = ThumbnailUtils.extractThumbnail(bitmap, sizeHint.x, sizeHint.y)
val baos = ByteArrayOutputStream()
thumb.compress(Bitmap.CompressFormat.JPEG, 95, baos)
return@execute baos.toByteArray()
}
} else
logger.warning("Couldn't download image for thumbnail (${response.status})")
}
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't download image for thumbnail", e)
}
null
}
companion object {

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
}