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
13 changed files with 261 additions and 327 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

@@ -20,7 +20,6 @@ import at.bitfire.davdroid.repository.PreferenceRepository
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.sync.TasksAppManager
import at.bitfire.davdroid.ui.intro.BackupsPage
import at.bitfire.davdroid.ui.intro.BatteryOptimizationsPageModel
import at.bitfire.davdroid.ui.intro.OpenSourcePage
import at.bitfire.davdroid.util.PermissionUtils
@@ -103,7 +102,6 @@ class AppSettingsModel @Inject constructor(
settings.remove(BatteryOptimizationsPageModel.HINT_BATTERY_OPTIMIZATIONS)
settings.remove(BatteryOptimizationsPageModel.HINT_AUTOSTART_PERMISSION)
settings.remove(TasksModel.HINT_OPENTASKS_NOT_INSTALLED)
settings.remove(BackupsPage.Model.SETTING_BACKUPS_ACCEPTED)
settings.remove(OpenSourcePage.Model.SETTING_NEXT_DONATION_POPUP)
}

View File

@@ -1,135 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.intro
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Backup
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import at.bitfire.davdroid.R
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.ui.AppTheme
import at.bitfire.davdroid.ui.composable.CardWithImage
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
class BackupsPage @Inject constructor(
val settingsManager: SettingsManager
): IntroPage() {
override fun getShowPolicy(): ShowPolicy =
if (Model.backupsAccepted(settingsManager))
ShowPolicy.DONT_SHOW
else
ShowPolicy.SHOW_ALWAYS
@Composable
override fun ComposePage() {
val model = hiltViewModel<Model>()
val accepted by model.backupsAcceptedFlow.collectAsStateWithLifecycle(false)
BackupsPage(
accepted = accepted,
updateAccepted = model::setBackupsAccepted
)
}
@HiltViewModel
class Model @Inject constructor(
private val settings: SettingsManager
): ViewModel() {
val backupsAcceptedFlow = settings.getBooleanFlow(SETTING_BACKUPS_ACCEPTED, false)
fun setBackupsAccepted(accepted: Boolean) {
settings.putBoolean(SETTING_BACKUPS_ACCEPTED, accepted)
}
companion object {
/** boolean setting (default: false) */
const val SETTING_BACKUPS_ACCEPTED = "intro_backups_accepted"
fun backupsAccepted(settingsManager: SettingsManager): Boolean =
settingsManager.getBooleanOrNull(SETTING_BACKUPS_ACCEPTED) ?: false
}
}
}
@Composable
fun BackupsPage(
accepted: Boolean,
updateAccepted: (Boolean) -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(8.dp)
) {
CardWithImage(
title = stringResource(R.string.intro_backups_title),
icon = Icons.Outlined.Backup,
modifier = Modifier.padding(vertical = 8.dp)
) {
Text(
text = stringResource(R.string.intro_backups_important),
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
text = stringResource(R.string.intro_backups_no_versioning, stringResource(R.string.app_name)),
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 8.dp, bottom = 16.dp)
) {
Checkbox(
checked = accepted,
onCheckedChange = updateAccepted
)
Text(
text = stringResource(R.string.intro_backups_accept),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.clickable { updateAccepted(!accepted) }
.padding(start = 8.dp)
)
}
}
}
}
@Preview
@Composable
fun BackupsPagePreview() {
AppTheme {
BackupsPage(
accepted = true,
updateAccepted = {}
)
}
}

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
}

View File

@@ -58,10 +58,6 @@
<string name="intro_tasks_tasks_org_info"><![CDATA[Some features <a href="https://www.davx5.com/faq/tasks/advanced-task-features">are not supported</a>.]]></string>
<string name="intro_tasks_no_app_store">No app store available</string>
<string name="intro_tasks_dont_show">I don\'t need tasks support.*</string>
<string name="intro_backups_title">Backups reminder</string>
<string name="intro_backups_important">It\'s important to back up your data (including contacts and calendars) regularly to prevent potential data loss.</string>
<string name="intro_backups_no_versioning">%s synchronizes changes and deletions but does NOT function as a backup tool or provide version history.</string>
<string name="intro_backups_accept">I already have a backup solution in place or I don\'t need one.</string>
<string name="intro_open_source_title">Open-source software</string>
<string name="intro_open_source_text">We\'re happy that you use %s, which is open-source software. Development, maintenance and support are hard work. Please consider contributing (there are many ways) or a donation. It would be highly appreciated!</string>
<string name="intro_open_source_details">How to contribute/donate</string>

View File

@@ -7,7 +7,6 @@ package at.bitfire.davdroid.ui.intro
import javax.inject.Inject
class OseIntroPageFactory @Inject constructor(
backupsPage: BackupsPage,
batteryOptimizationsPage: BatteryOptimizationsPage,
openSourcePage: OpenSourcePage,
permissionsIntroPage: PermissionsIntroPage,
@@ -19,7 +18,6 @@ class OseIntroPageFactory @Inject constructor(
tasksIntroPage,
permissionsIntroPage,
batteryOptimizationsPage,
backupsPage,
openSourcePage
)