mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2026-01-23 06:07:59 -05:00
Compare commits
7 Commits
backups-sc
...
ktor-op2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
722edc1ea7 | ||
|
|
7b68ec0832 | ||
|
|
530e62f1f4 | ||
|
|
f0a8694436 | ||
|
|
dd839f5b96 | ||
|
|
2c7b36ecd5 | ||
|
|
cf80b11808 |
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user