Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Unverified Commit cf80b118 authored by Ricki Hirner's avatar Ricki Hirner Committed by GitHub
Browse files

[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: default avatarArnau Mora <arnyminerz@proton.me>
parent 18649f71
Loading
Loading
Loading
Loading
+5 −0
Original line number Diff line number Diff line
@@ -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).
+28 −1
Original line number Diff line number Diff line
@@ -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
    }


+56 −41
Original line number Diff line number Diff line
@@ -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,46 +71,63 @@ 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()
            createThumbnail(doc, sizeHint, signal)
        }

        if (thumbFile != null)
            return AssetFileDescriptor(
                ParcelFileDescriptor.open(thumbFile, ParcelFileDescriptor.MODE_READ_ONLY),
                0, thumbFile.length()
            )

        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)
                }
                    result
            }
        } 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 {
                runBlocking {
                    job.await()
                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()
                        }
            } catch (e: Exception) {
                logger.log(Level.WARNING, "Couldn't generate thumbnail", e)
                null
                    } 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)
            }

        if (thumbFile != null)
            return AssetFileDescriptor(
                ParcelFileDescriptor.open(thumbFile, ParcelFileDescriptor.MODE_READ_ONLY),
                0, thumbFile.length()
            )

        return null
            null
        }