Compare commits

..

3 Commits

Author SHA1 Message Date
Ricki Hirner
439639d204 Add to "Reset hints" 2026-01-21 15:13:17 +01:00
Ricki Hirner
49cce6aa8f Add BackupsPage UI
- Implement BackupsPage composable with UI elements
- Add strings for backups reminder and acceptance
2026-01-21 14:56:50 +01:00
Ricki Hirner
a1997409b7 Add BackupsPage to intro UI 2026-01-21 14:25:38 +01:00
13 changed files with 327 additions and 261 deletions

View File

@@ -13,10 +13,8 @@ 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
@@ -130,9 +128,6 @@ 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,7 +69,6 @@ class HttpClientBuilder @Inject constructor(
) {
companion object {
init {
// make sure Conscrypt is available when the HttpClientBuilder class is loaded the first time
ConscryptIntegration().initialize()
@@ -85,18 +84,12 @@ 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().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
}
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()
}
/**
@@ -414,11 +407,6 @@ 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,13 +12,11 @@ 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.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.okhttp.DavCollection
import at.bitfire.dav4jvm.okhttp.DavResource
import at.bitfire.dav4jvm.okhttp.exception.DavException
import at.bitfire.dav4jvm.property.push.WebDAVPush
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
@@ -31,14 +29,15 @@ 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
@@ -177,26 +176,24 @@ class PushRegistrationManager @Inject constructor(
return
val account = accountRepository.get().fromName(service.accountName)
httpClientBuilder.get()
val httpClient = httpClientBuilder.get()
.fromAccountAsync(account)
.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)
.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)
}
}
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't register subscription at CalDAV/CardDAV server", e)
}
}
/**
@@ -227,7 +224,7 @@ class PushRegistrationManager @Inject constructor(
* @param collection collection to subscribe to
* @param endpoint subscription to register
*/
private suspend fun subscribe(httpClient: HttpClient, collection: Collection, endpoint: PushEndpoint) {
private suspend fun subscribe(httpClient: OkHttpClient, collection: Collection, endpoint: PushEndpoint) {
// requested expiration time: 3 days
val requestedExpiration = Instant.now() + Duration.ofDays(3)
@@ -260,24 +257,26 @@ class PushRegistrationManager @Inject constructor(
}
serializer.endDocument()
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
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
collectionRepository.updatePushSubscription(
id = collection.id,
subscriptionUrl = subscriptionUrl,
expires = expires?.epochSecond
)
} else
logger.warning("Couldn't register push for ${collection.url}: $response")
runBlocking {
collectionRepository.updatePushSubscription(
id = collection.id,
subscriptionUrl = subscriptionUrl,
expires = expires?.epochSecond
)
}
} else
logger.warning("Couldn't register push for ${collection.url}: $response")
}
}
}
@@ -289,22 +288,22 @@ class PushRegistrationManager @Inject constructor(
return
val account = accountRepository.get().fromName(service.accountName)
httpClientBuilder.get()
val httpClient = httpClientBuilder.get()
.fromAccountAsync(account)
.buildKtor()
.use { httpClient ->
for (collection in from)
collection.pushSubscription?.toUrlOrNull()?.let { url ->
logger.info("Unsubscribing Push from ${collection.url}")
unsubscribe(httpClient, collection, url)
}
}
.build()
for (collection in from)
collection.pushSubscription?.toHttpUrlOrNull()?.let { url ->
logger.info("Unsubscribing Push from ${collection.url}")
unsubscribe(httpClient, collection, url)
}
}
private suspend fun unsubscribe(httpClient: HttpClient, collection: Collection, url: Url) {
private suspend fun unsubscribe(httpClient: OkHttpClient, collection: Collection, url: HttpUrl) {
try {
DavResource(httpClient, url).delete {
// deleted
runInterruptible(ioDispatcher) {
DavResource(httpClient, url).delete {
// deleted
}
}
} catch (e: DavException) {
logger.log(Level.WARNING, "Couldn't unregister push for ${collection.url}", e)

View File

@@ -20,6 +20,7 @@ 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
@@ -102,6 +103,7 @@ 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

@@ -0,0 +1,135 @@
/*
* 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,8 +6,6 @@ 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
@@ -26,31 +24,6 @@ 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()
}
@@ -65,7 +38,7 @@ class DavHttpClientBuilder @Inject constructor(
)
}
return builder
return builder.build()
}

View File

@@ -13,19 +13,15 @@ import android.provider.DocumentsContract.buildChildDocumentsUri
import android.provider.DocumentsContract.buildRootsUri
import android.webkit.MimeTypeMap
import androidx.core.app.TaskStackBuilder
import at.bitfire.dav4jvm.ktor.exception.HttpException
import at.bitfire.dav4jvm.okhttp.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() }
@@ -41,23 +37,24 @@ object DocumentProviderUtils {
}
internal fun notifyFolderChanged(context: Context, parentDocumentId: Long?) {
if (parentDocumentId != null) {
val uri = buildChildDocumentsUri(
context.getString(R.string.webdav_authority),
parentDocumentId.toString()
if (parentDocumentId != null)
context.contentResolver.notifyChange(
buildChildDocumentsUri(
context.getString(R.string.webdav_authority),
parentDocumentId.toString()
),
null
)
logger.fine("Notifying observers of $uri")
context.contentResolver.notifyChange(uri, null)
}
}
internal fun notifyFolderChanged(context: Context, parentDocumentId: String) {
val uri = buildChildDocumentsUri(
context.getString(R.string.webdav_authority),
parentDocumentId
context.contentResolver.notifyChange(
buildChildDocumentsUri(
context.getString(R.string.webdav_authority),
parentDocumentId
),
null
)
logger.fine("Notifying observers of $uri")
context.contentResolver.notifyChange(uri, null)
}
internal fun notifyMountsChanged(context: Context) {
@@ -69,20 +66,12 @@ 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(
ex,
this,
TaskStackBuilder.create(context)
.addNextIntentWithParentStack(intent)
.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
@@ -97,5 +86,5 @@ private fun throwForDocumentProvider(context: Context, statusCode: Int, ex: Exce
}
// re-throw
throw ex
throw this
}

View File

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

View File

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

@@ -14,22 +14,19 @@ 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
@@ -61,6 +58,11 @@ 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()
@@ -71,7 +73,37 @@ class OpenDocumentThumbnailOperation @Inject constructor(
}
val thumbFile = thumbnailCache.get(docCacheKey, sizeHint) {
createThumbnail(doc, sizeHint, signal)
// 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
}
}
if (thumbFile != null)
@@ -83,53 +115,6 @@ 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.ktor.DavResource
import at.bitfire.dav4jvm.ktor.exception.HttpException
import at.bitfire.dav4jvm.okhttp.DavResource
import at.bitfire.dav4jvm.okhttp.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,36 +31,34 @@ class RenameDocumentOperation @Inject constructor(
private val documentDao = db.webDavDocumentDao()
operator fun invoke(documentId: String, displayName: String): String? = runBlocking(ioDispatcher) {
operator fun invoke(documentId: String, displayName: String): String? = runBlocking {
logger.fine("WebDAV renameDocument $documentId $displayName")
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
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)
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
}
}
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,6 +58,10 @@
<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,6 +7,7 @@ package at.bitfire.davdroid.ui.intro
import javax.inject.Inject
class OseIntroPageFactory @Inject constructor(
backupsPage: BackupsPage,
batteryOptimizationsPage: BatteryOptimizationsPage,
openSourcePage: OpenSourcePage,
permissionsIntroPage: PermissionsIntroPage,
@@ -18,6 +19,7 @@ class OseIntroPageFactory @Inject constructor(
tasksIntroPage,
permissionsIntroPage,
batteryOptimizationsPage,
backupsPage,
openSourcePage
)