Compare commits

..

19 Commits

Author SHA1 Message Date
Ricki Hirner
a7b2b79477 Add tests for caching 2026-01-27 20:53:19 +01:00
Ricki Hirner
c37781ebf5 Minor changes
- Change socketFactoryCache to use LinkedHashMap instead of ConcurrentHashMap
- Update cache key handling to use String? instead of Optional<String>
2026-01-27 20:51:03 +01:00
Ricki Hirner
eacfde096d Refactor socket factory cache to store only SSLSocketFactory 2026-01-27 20:43:07 +01:00
Ricki Hirner
fa99d03a2e Add tests 2026-01-27 20:30:50 +01:00
Ricki Hirner
d642b6e37c Refactor socket factory caching logic for better clarity 2026-01-27 17:37:23 +01:00
Ricki Hirner
647be71192 Update ConnectionSecurityManager to use SSLSocketFactory caching 2026-01-27 17:17:58 +01:00
Ricki Hirner
218559afb6 Update comments in HttpClientBuilder.kt for clarity 2026-01-26 17:45:27 +01:00
Ricki Hirner
256b3381c9 [WIP] Cache SSLContext by certificate alias
- Add context cache using Guava CacheBuilder
- Cache SSLContext in getContext method
2026-01-25 13:25:03 +01:00
Ricki Hirner
ee57967152 Implement connection security manager for HTTP client
- Introduce `ConnectionSecurityManager` and `ConnectionSecurityContext` classes
- Refactor `HttpClientBuilder` to use the new security manager for SSL context setup
2026-01-25 13:07:06 +01:00
Ricki Hirner
a144180c70 Reuse CustomCertManager
- Update bitfire-cert4android to 75cc6913fd
- Refactor HttpClientBuilder to use Optional for customTrustManager and customHostnameVerifier
- Add CustomCertManagerModule for dependency injection
2026-01-25 12:43:48 +01:00
Ricki Hirner
47685e6693 [WebDAV] Rewrite COPY/MOVE (including rename) to Ktor (#1940)
* [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`

* Rewrite CopyDocumentOperation.kt to Ktor

* Refactor URLBuilder usage

- Update URLBuilder usage in RenameDocumentOperation.kt
- Update URLBuilder usage in CopyDocumentOperation.kt
- Update URLBuilder usage in MoveDocumentOperation.kt

* - Pass `ioDispatcher` to `runBlocking` in WebDAV operations
- Refactor timeout configuration in HttpClientBuilder for reusability

* Add logging to DocumentProviderUtils

- Introduce a logger instance
- Log URI when notifying folder changes
2026-01-24 19:28:50 +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
Sunik Kupfer
18649f711a DmfsTaskList refactoring (#1934)
* DmfsTaskList refactoring

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Update synctools

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2026-01-21 13:17:34 +01:00
Sunik Kupfer
377a159e75 Ignore test with flaky behaviour in CI (#1936)
Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2026-01-21 13:17:23 +01:00
Ricki Hirner
393d22f720 AGP 9.0: update Hilt, remove Kotlin Android plugin (#1935)
- Remove `android.builtInKotlin` from `gradle.properties`
- Update Hilt version to 2.59
- Remove Kotlin Android plugin from `libs.versions.toml` and build scripts
2026-01-21 11:28:40 +01:00
dependabot[bot]
5b12ecf6b6 Bump the app-dependencies group across 1 directory with 2 updates (#1933)
Bumps the app-dependencies group with 2 updates in the / directory: androidx.compose:compose-bom and [dnsjava:dnsjava](https://github.com/dnsjava/dnsjava).


Updates `androidx.compose:compose-bom` from 2025.12.01 to 2026.01.00

Updates `dnsjava:dnsjava` from 3.6.3 to 3.6.4
- [Release notes](https://github.com/dnsjava/dnsjava/releases)
- [Changelog](https://github.com/dnsjava/dnsjava/blob/master/Changelog)
- [Commits](https://github.com/dnsjava/dnsjava/compare/v3.6.3...v3.6.4)

---
updated-dependencies:
- dependency-name: androidx.compose:compose-bom
  dependency-version: 2026.01.00
  dependency-type: direct:production
  dependency-group: app-dependencies
- dependency-name: dnsjava:dnsjava
  dependency-version: 3.6.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 16:34:51 +01:00
Ricki Hirner
f8f6134640 Update AGP to 9.0.0 (#1929)
* Update gradle wrapper

* Update AGP to 9.0.0 (legacy mode) and synctools (which now also uses AGP 9.0.0)
2026-01-20 12:45:58 +01:00
Ricki Hirner
0f7908da23 Remove Transifex config/scripts (#1924) 2026-01-19 10:59:34 +01:00
27 changed files with 712 additions and 377 deletions

View File

@@ -1,32 +0,0 @@
[main]
host = https://www.transifex.com
lang_map = ar_SA: ar, en_GB: en-rGB, fi_FI: fi, mr_IN: mr-rIN, nb_NO: nb, pt_BR: pt-rBR, sk_SK: sk, sl_SI: sl, tr_TR: tr, zh_CN: zh, zh_TW: zh-rTW
[o:bitfireAT:p:davx5:r:app]
file_filter = app/src/main/res/values-<lang>/strings.xml
source_file = app/src/main/res/values/strings.xml
source_lang = en
type = ANDROID
minimum_perc = 20
resource_name = App strings (all flavors)
# Attention: fastlane directories are like "en-us", not "en-rUS"!
[o:bitfireAT:p:davx5:r:metadata-short-description]
file_filter = fastlane/metadata/android/<lang>/short_description.txt
source_file = fastlane/metadata/android/en-US/short_description.txt
source_lang = en
type = TXT
minimum_perc = 100
resource_name = Metadata: short description
lang_map = ar_SA: ar, en_GB: en-rGB, fi_FI: fi, mr_IN: mr-rIN, nb_NO: nb, pt_BR: pt-rBR, sk_SK: sk, sl_SI: sl, tr_TR: tr, zh_CN: zh, zh_TW: zh-rTW
[o:bitfireAT:p:davx5:r:metadata-full-description]
file_filter = fastlane/metadata/android/<lang>/full_description.txt
source_file = fastlane/metadata/android/en-US/full_description.txt
source_lang = en
type = TXT
minimum_perc = 100
resource_name = Metadata: full description
lang_map = ar_SA: ar, en_GB: en-rGB, fi_FI: fi, mr_IN: mr-rIN, nb_NO: nb, pt_BR: pt-rBR, sk_SK: sk, sl_SI: sl, tr_TR: tr, zh_CN: zh, zh_TW: zh-rTW

View File

@@ -6,7 +6,6 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.mikepenz.aboutLibraries.android)

View File

@@ -22,6 +22,7 @@ import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assume
import org.junit.Before
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
@@ -62,6 +63,7 @@ class LocalCalendarStoreTest {
}
@Ignore("Flaky in CI")
@Test
fun testUpdateAccount_updatesOwnerAccount() {
// Verify initial state (assume to skip and prevent flaky test failures)
@@ -76,7 +78,6 @@ class LocalCalendarStoreTest {
// Verify [Calendar.OWNER_ACCOUNT] of local calendar was updated
assertEquals("ChangedAccountName", getOwnerAccount())
}

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

@@ -0,0 +1,65 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.di
import android.content.Context
import at.bitfire.cert4android.CustomCertManager
import at.bitfire.cert4android.CustomCertStore
import at.bitfire.cert4android.SettingsProvider
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.ui.ForegroundTracker
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import okhttp3.internal.tls.OkHostnameVerifier
import java.util.Optional
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
/**
* cert4android integration module
*/
class CustomCertManagerModule {
@Provides
@Singleton
fun customCertManager(
@ApplicationContext context: Context,
settings: SettingsManager
): Optional<CustomCertManager> =
if (BuildConfig.allowCustomCerts)
Optional.of(
CustomCertManager(
certStore = CustomCertStore.getInstance(context),
settings = object : SettingsProvider {
override val appInForeground: Boolean
get() = ForegroundTracker.inForeground.value
override val trustSystemCerts: Boolean
get() = !settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES)
}
))
else
Optional.empty()
@Provides
@Singleton
fun customHostnameVerifier(
customCertManager: Optional<CustomCertManager>
): Optional<CustomCertManager.HostnameVerifier> =
if (BuildConfig.allowCustomCerts && customCertManager.isPresent) {
val hostnameVerifier = customCertManager.get().HostnameVerifier(OkHostnameVerifier)
Optional.of(hostnameVerifier)
} else
Optional.empty()
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509TrustManager
/**
* Holds information that shall be used to create TLS connections.
*
* @param sslSocketFactory the socket factory that shall be used
* @param trustManager the trust manager that shall be used
* @param hostnameVerifier the hostname verifier that shall be used
* @param disableHttp2 whether HTTP/2 shall be disabled
*/
class ConnectionSecurityContext(
val sslSocketFactory: SSLSocketFactory?,
val trustManager: X509TrustManager?,
val hostnameVerifier: HostnameVerifier?,
val disableHttp2: Boolean
)

View File

@@ -0,0 +1,103 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import androidx.annotation.VisibleForTesting
import at.bitfire.cert4android.CustomCertManager
import java.lang.ref.SoftReference
import java.security.KeyStore
import java.util.Optional
import javax.inject.Inject
import javax.inject.Singleton
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
import kotlin.jvm.optionals.getOrNull
/**
* Caching provider for [ConnectionSecurityContext].
*/
@Singleton
class ConnectionSecurityManager @Inject constructor(
private val customHostnameVerifier: Optional<CustomCertManager.HostnameVerifier>,
private val customTrustManager: Optional<CustomCertManager>,
private val keyManagerFactory: ClientCertKeyManager.Factory
) {
/**
* Maps client certificate aliases (or `null` if no client authentication is used) to their SSLSocketFactory.
* Uses soft references for the values so that they can be garbage-collected when not used anymore.
*
* Not thread-safe, access must be synchronized by caller.
*/
private val socketFactoryCache: MutableMap<String?, SoftReference<SSLSocketFactory>> =
LinkedHashMap(2) // usually not more than: one for no client certificates + one for a certain certificate alias
/**
* The default TrustManager to use for connections. If [customTrustManager] provides a value, that value is
* used. Otherwise, the platform's default trust manager is used.
*/
private val trustManager by lazy { customTrustManager.getOrNull() ?: defaultTrustManager() }
/**
* Provides the [ConnectionSecurityContext] for a given [certificateAlias].
*
* Uses [socketFactoryCache] to cache the entries (per [certificateAlias]).
*
* @param certificateAlias alias of the client certificate that shall be used for authentication (`null` for none)
* @return the connection security context
*/
fun getContext(certificateAlias: String?): ConnectionSecurityContext {
/* We only need a custom socket factory for
- client certificates and/or
- when cert4android is active (= there's a custom trustManager). */
val socketFactory = if (certificateAlias != null || customTrustManager.isPresent)
getSocketFactory(certificateAlias)
else
null
return ConnectionSecurityContext(
sslSocketFactory = socketFactory,
trustManager = if (socketFactory != null) trustManager else null, // when there's a customTrustManager, there's always a socketFactory, too
hostnameVerifier = customHostnameVerifier.getOrNull(),
disableHttp2 = certificateAlias != null
)
}
@VisibleForTesting
internal fun getSocketFactory(certificateAlias: String?): SSLSocketFactory = synchronized(socketFactoryCache) {
// look up cache first
val cachedFactory = socketFactoryCache[certificateAlias]?.get()
if (cachedFactory != null)
return cachedFactory
// no cached value, calculate and store into cache
// when a client certificate alias is given, create and use the respective ClientKeyManager
val clientKeyManager = certificateAlias?.let { keyManagerFactory.create(it) }
// create SSLContext that provides the SSLSocketFactory
val sslContext = SSLContext.getInstance("TLS").apply {
init(
/* km = */ if (clientKeyManager != null) arrayOf(clientKeyManager) else null,
/* tm = */ arrayOf(trustManager),
/* random = */ null /* default RNG */
)
}
// cache reference and return socket factory
return sslContext.socketFactory.also { socketFactory ->
socketFactoryCache[certificateAlias] = SoftReference(socketFactory)
}
}
@VisibleForTesting
internal fun defaultTrustManager(): X509TrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
return factory.trustManagers.filterIsInstance<X509TrustManager>().first()
}
}

View File

@@ -5,22 +5,16 @@
package at.bitfire.davdroid.network
import android.accounts.Account
import android.content.Context
import androidx.annotation.WorkerThread
import at.bitfire.cert4android.CustomCertManager
import at.bitfire.cert4android.CustomCertStore
import at.bitfire.dav4jvm.okhttp.BasicDigestAuthHandler
import at.bitfire.dav4jvm.okhttp.UrlUtils
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Credentials
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.ui.ForegroundTracker
import com.google.common.net.HttpHeaders
import com.google.errorprone.annotations.MustBeClosed
import dagger.hilt.android.qualifiers.ApplicationContext
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
@@ -35,20 +29,13 @@ import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.brotli.BrotliInterceptor
import okhttp3.internal.tls.OkHostnameVerifier
import okhttp3.logging.HttpLoggingInterceptor
import java.net.InetSocketAddress
import java.net.Proxy
import java.security.KeyStore
import java.util.concurrent.TimeUnit
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.KeyManager
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
/**
* Builder for the HTTP client.
@@ -60,15 +47,15 @@ import javax.net.ssl.X509TrustManager
*/
class HttpClientBuilder @Inject constructor(
private val accountSettingsFactory: AccountSettings.Factory,
@ApplicationContext private val context: Context,
private val connectionSecurityManager: ConnectionSecurityManager,
defaultLogger: Logger,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val keyManagerFactory: ClientCertKeyManager.Factory,
private val oAuthInterceptorFactory: OAuthInterceptor.Factory,
private val settingsManager: SettingsManager
) {
companion object {
init {
// make sure Conscrypt is available when the HttpClientBuilder class is loaded the first time
ConscryptIntegration().initialize()
@@ -84,12 +71,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
}
}
/**
@@ -276,72 +269,29 @@ class HttpClientBuilder @Inject constructor(
}
private fun buildConnectionSecurity(okBuilder: OkHttpClient.Builder) {
// allow cleartext and TLS 1.2+
// Allow cleartext and TLS 1.2+
okBuilder.connectionSpecs(listOf(
ConnectionSpec.CLEARTEXT,
ConnectionSpec.MODERN_TLS
))
// client certificate
val clientKeyManager: KeyManager? = certificateAlias?.let { alias ->
try {
val manager = keyManagerFactory.create(alias)
logger.fine("Using certificate $alias for authentication")
/* Set SSLSocketFactory, TrustManager and HostnameVerifier (if needed).
* We shouldn't create these things here, because
*
* a. it involves complex logic that should be the responsibility of a dedicated class, and
* b. we need to cache the instances because otherwise, HTTPS connection are not used
* correctly. okhttp checks the SSLSocketFactory/TrustManager of a connection in the pool
* and creates a new connection when they have changed. */
val securityContext = connectionSecurityManager.getContext(certificateAlias)
// HTTP/2 doesn't support client certificates (yet)
// see https://datatracker.ietf.org/doc/draft-ietf-httpbis-secondary-server-certs/
okBuilder.protocols(listOf(Protocol.HTTP_1_1))
if (securityContext.disableHttp2)
okBuilder.protocols(listOf(Protocol.HTTP_1_1))
manager
} catch (e: IllegalArgumentException) {
logger.log(Level.SEVERE, "Couldn't create KeyManager for certificate $alias", e)
null
}
}
if (securityContext.sslSocketFactory != null && securityContext.trustManager != null)
okBuilder.sslSocketFactory(securityContext.sslSocketFactory, securityContext.trustManager)
// select trust manager and hostname verifier depending on whether custom certificates are allowed
val customTrustManager: X509TrustManager?
val customHostnameVerifier: HostnameVerifier?
if (BuildConfig.allowCustomCerts) {
// use cert4android for custom certificate handling
customTrustManager = CustomCertManager(
certStore = CustomCertStore.getInstance(context),
trustSystemCerts = !settingsManager.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES),
appInForeground = ForegroundTracker.inForeground
)
// allow users to accept certificates with wrong host names
customHostnameVerifier = customTrustManager.HostnameVerifier(OkHostnameVerifier)
} else {
// no custom certificates, use default trust manager and hostname verifier
customTrustManager = null
customHostnameVerifier = null
}
// change settings only if we have at least only one custom component
if (clientKeyManager != null || customTrustManager != null) {
val trustManager = customTrustManager ?: defaultTrustManager()
// use trust manager and client key manager (if defined) for TLS connections
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(
/* km = */ if (clientKeyManager != null) arrayOf(clientKeyManager) else null,
/* tm = */ arrayOf(trustManager),
/* random = */ null
)
okBuilder.sslSocketFactory(sslContext.socketFactory, trustManager)
}
// also add the custom hostname verifier (if defined)
if (customHostnameVerifier != null)
okBuilder.hostnameVerifier(customHostnameVerifier)
}
private fun defaultTrustManager(): X509TrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
return factory.trustManagers.filterIsInstance<X509TrustManager>().first()
if (securityContext.hostnameVerifier != null)
okBuilder.hostnameVerifier(securityContext.hostnameVerifier)
}
private fun buildProxy(okBuilder: OkHttpClient.Builder) {
@@ -407,6 +357,11 @@ class HttpClientBuilder @Inject constructor(
config {
// OkHttpClient.Builder configuration here
// we don't use the sharedOkHttpClient, so we have to apply timeouts again
configureTimeouts(this)
// build most config on okhttp level
configureOkHttp(this)
}
}

View File

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

View File

@@ -6,7 +6,7 @@ package at.bitfire.davdroid.resource
import androidx.core.content.contentValuesOf
import at.bitfire.ical4android.DmfsTask
import at.bitfire.ical4android.DmfsTaskList
import at.bitfire.synctools.storage.tasks.DmfsTaskList
import org.dmfs.tasks.contract.TaskContract.TaskListColumns
import org.dmfs.tasks.contract.TaskContract.Tasks
import java.util.logging.Level
@@ -43,11 +43,11 @@ class LocalTaskList (
dmfsTaskList.writeSyncState(state.toString())
}
override fun findDeleted() = dmfsTaskList.queryTasks(Tasks._DELETED, null)
override fun findDeleted() = dmfsTaskList.findTasks(Tasks._DELETED, null)
.map { LocalTask(it) }
override fun findDirty(): List<LocalTask> {
val dmfsTasks = dmfsTaskList.queryTasks(Tasks._DIRTY, null)
val dmfsTasks = dmfsTaskList.findTasks(Tasks._DIRTY, null)
for (localTask in dmfsTasks) {
try {
val task = requireNotNull(localTask.task)
@@ -64,28 +64,31 @@ class LocalTaskList (
}
override fun findByName(name: String) =
dmfsTaskList.queryTasks("${Tasks._SYNC_ID}=?", arrayOf(name))
dmfsTaskList.findTasks("${Tasks._SYNC_ID}=?", arrayOf(name))
.firstOrNull()?.let {
LocalTask(it)
}
override fun markNotDirty(flags: Int): Int {
val values = contentValuesOf(DmfsTask.COLUMN_FLAGS to flags)
return dmfsTaskList.provider.update(dmfsTaskList.tasksSyncUri(), values,
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0",
arrayOf(dmfsTaskList.id.toString()))
}
override fun markNotDirty(flags: Int): Int =
dmfsTaskList.updateTasks(
contentValuesOf(DmfsTask.COLUMN_FLAGS to flags),
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0",
arrayOf(dmfsTaskList.id.toString())
)
override fun removeNotDirtyMarked(flags: Int) =
dmfsTaskList.provider.delete(dmfsTaskList.tasksSyncUri(),
"${Tasks.LIST_ID}=? AND NOT ${Tasks._DIRTY} AND ${DmfsTask.COLUMN_FLAGS}=?",
arrayOf(dmfsTaskList.id.toString(), flags.toString()))
dmfsTaskList.deleteTasks(
"${Tasks.LIST_ID}=? AND NOT ${Tasks._DIRTY} AND ${DmfsTask.COLUMN_FLAGS}=?",
arrayOf(dmfsTaskList.id.toString(), flags.toString())
)
override fun forgetETags() {
val values = contentValuesOf(DmfsTask.COLUMN_ETAG to null)
dmfsTaskList.provider.update(dmfsTaskList.tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
arrayOf(dmfsTaskList.id.toString()))
dmfsTaskList.updateTasks(
contentValuesOf(DmfsTask.COLUMN_ETAG to null),
"${Tasks.LIST_ID}=?",
arrayOf(dmfsTaskList.id.toString())
)
}
}

View File

@@ -6,10 +6,8 @@ package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
@@ -17,8 +15,9 @@ import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.util.DavUtils.lastSegment
import at.bitfire.ical4android.DmfsTaskList
import at.bitfire.ical4android.TaskProvider
import at.bitfire.synctools.storage.tasks.DmfsTaskList
import at.bitfire.synctools.storage.tasks.DmfsTaskListProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@@ -62,11 +61,11 @@ class LocalTaskListStore @AssistedInject constructor(
val account = Account(service.accountName, context.getString(R.string.account_type))
logger.log(Level.INFO, "Adding local task list", fromCollection)
val uri = create(account, client, providerName, fromCollection)
return LocalTaskList(DmfsTaskList.findByID(account, client, providerName, ContentUris.parseId(uri)))
val dmfsTaskList = create(account, client, providerName, fromCollection)
return LocalTaskList(dmfsTaskList)
}
private fun create(account: Account, provider: ContentProviderClient, providerName: TaskProvider.ProviderName, fromCollection: Collection): Uri {
private fun create(account: Account, client: ContentProviderClient, providerName: TaskProvider.ProviderName, fromCollection: Collection): DmfsTaskList {
// If the collection doesn't have a color, use a default color.
val collectionWithColor = if (fromCollection.color != null)
fromCollection
@@ -81,7 +80,8 @@ class LocalTaskListStore @AssistedInject constructor(
put(TaskLists.SYNC_ENABLED, 1)
put(TaskLists.VISIBLE, 1)
}
return DmfsTaskList.create(account, provider, providerName, values)
val dmfsTaskListProvider = DmfsTaskListProvider(account, client, providerName)
return dmfsTaskListProvider.createAndGetTaskList(values)
}
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
@@ -102,7 +102,7 @@ class LocalTaskListStore @AssistedInject constructor(
}
override fun getAll(account: Account, client: ContentProviderClient) =
DmfsTaskList.find(account, client, providerName, null, null)
DmfsTaskListProvider(account, client, providerName).findTaskLists()
.map { LocalTaskList(it) }
override fun update(client: ContentProviderClient, localCollection: LocalTaskList, fromCollection: Collection) {

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

@@ -0,0 +1,197 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import at.bitfire.cert4android.CustomCertManager
import io.mockk.mockk
import io.mockk.verify
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Optional
class ConnectionSecurityManagerTest {
@Test
fun `getContext(no customTrustManager, no client certificate)`() {
val manager = ConnectionSecurityManager(
customTrustManager = Optional.empty(),
customHostnameVerifier = Optional.empty(),
keyManagerFactory = mockk()
)
val context = manager.getContext(null)
assertNull(context.sslSocketFactory)
assertNull(context.trustManager)
assertNull(context.hostnameVerifier)
assertFalse(context.disableHttp2)
}
@Test
fun `getContext(no customTrustManager, with client certificate)`() {
val kmf: ClientCertKeyManager.Factory = mockk(relaxed = true)
val manager = ConnectionSecurityManager(
customTrustManager = Optional.empty(),
customHostnameVerifier = Optional.empty(),
keyManagerFactory = kmf
)
val context = manager.getContext("alias")
assertNotNull(context.sslSocketFactory)
assertEquals(manager.defaultTrustManager().javaClass, context.trustManager?.javaClass)
assertNull(context.hostnameVerifier)
assertTrue(context.disableHttp2)
verify(exactly = 1) {
kmf.create("alias")
}
}
@Test
fun `getContext(with customTrustManager, no client certificate)`() {
val customTrustManager: CustomCertManager = mockk()
val customHostnameVerifier: CustomCertManager.HostnameVerifier = mockk()
val manager = ConnectionSecurityManager(
customTrustManager = Optional.of(customTrustManager),
customHostnameVerifier = Optional.of(customHostnameVerifier),
keyManagerFactory = mockk()
)
val context = manager.getContext(null)
assertNotNull(context.sslSocketFactory)
assertEquals(customTrustManager, context.trustManager)
assertEquals(customHostnameVerifier, context.hostnameVerifier)
assertFalse(context.disableHttp2)
}
@Test
fun `getContext(with customTrustManager, with client certificate)`() {
val customTrustManager: CustomCertManager = mockk()
val customHostnameVerifier: CustomCertManager.HostnameVerifier = mockk()
val kmf: ClientCertKeyManager.Factory = mockk(relaxed = true)
val manager = ConnectionSecurityManager(
customTrustManager = Optional.of(customTrustManager),
customHostnameVerifier = Optional.of(customHostnameVerifier),
keyManagerFactory = kmf
)
val context = manager.getContext("alias")
assertNotNull(context.sslSocketFactory)
assertEquals(customTrustManager, context.trustManager)
assertEquals(customHostnameVerifier, context.hostnameVerifier)
assertTrue(context.disableHttp2)
verify(exactly = 1) {
kmf.create("alias")
}
}
@Test
fun `getSocketFactory(no customTrustManager, no client certificate)`() {
val manager = ConnectionSecurityManager(
customTrustManager = Optional.empty(),
customHostnameVerifier = Optional.empty(),
keyManagerFactory = mockk()
)
val socketFactory = manager.getSocketFactory(null)
assertNotNull(socketFactory.javaClass)
}
@Test
fun `getSocketFactory(no customTrustManager, with client certificate)`() {
val kmf: ClientCertKeyManager.Factory = mockk(relaxed = true)
val manager = ConnectionSecurityManager(
customTrustManager = Optional.empty(),
customHostnameVerifier = Optional.empty(),
keyManagerFactory = kmf
)
val socketFactory = manager.getSocketFactory("alias")
assertNotNull(socketFactory.javaClass)
verify(exactly = 1) {
kmf.create("alias")
}
}
@Test
fun `getSocketFactory(with customTrustManager, no client certificate)`() {
val customTrustManager: CustomCertManager = mockk()
val customHostnameVerifier: CustomCertManager.HostnameVerifier = mockk()
val manager = ConnectionSecurityManager(
customTrustManager = Optional.of(customTrustManager),
customHostnameVerifier = Optional.of(customHostnameVerifier),
keyManagerFactory = mockk()
)
val socketFactory = manager.getSocketFactory(null)
assertNotNull(socketFactory.javaClass)
}
@Test
fun `getSocketFactory(with customTrustManager, with client certificate)`() {
val customTrustManager: CustomCertManager = mockk()
val customHostnameVerifier: CustomCertManager.HostnameVerifier = mockk()
val kmf: ClientCertKeyManager.Factory = mockk(relaxed = true)
val manager = ConnectionSecurityManager(
customTrustManager = Optional.of(customTrustManager),
customHostnameVerifier = Optional.of(customHostnameVerifier),
keyManagerFactory = kmf
)
val socketFactory = manager.getSocketFactory("alias")
assertNotNull(socketFactory.javaClass)
verify(exactly = 1) {
kmf.create("alias")
}
}
@Test
fun `getContext caches socket factories for same certificateAlias`() {
val kmf: ClientCertKeyManager.Factory = mockk(relaxed = true)
val manager = ConnectionSecurityManager(
customTrustManager = Optional.empty(),
customHostnameVerifier = Optional.empty(),
keyManagerFactory = kmf
)
// First call - should create new socket factory
val context1 = manager.getContext("alias")
assertNotNull(context1.sslSocketFactory)
// Second call with same alias - should return cached socket factory
val context2 = manager.getContext("alias")
assertNotNull(context2.sslSocketFactory)
// Both should return the same socket factory instance
assert(context1.sslSocketFactory === context2.sslSocketFactory)
// Should only create key manager once
verify(exactly = 1) {
kmf.create("alias")
}
}
@Test
fun `getContext does not cache socket factories for different certificateAlias`() {
val kmf: ClientCertKeyManager.Factory = mockk(relaxed = true)
val manager = ConnectionSecurityManager(
customTrustManager = Optional.empty(),
customHostnameVerifier = Optional.empty(),
keyManagerFactory = kmf
)
// Get context for first alias
val context1 = manager.getContext("alias1")
assertNotNull(context1.sslSocketFactory)
// Get context for different alias
val context2 = manager.getContext("alias2")
assertNotNull(context2.sslSocketFactory)
// Should be different instances
assert(context1.sslSocketFactory !== context2.sslSocketFactory)
// Should create key managers for both aliases
verify(exactly = 1) {
kmf.create("alias1")
kmf.create("alias2")
}
}
}

View File

@@ -6,7 +6,6 @@ plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.compose.compiler) apply false
alias(libs.plugins.hilt) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.mikepenz.aboutLibraries.android) apply false

View File

@@ -10,6 +10,9 @@ org.gradle.parallel=true
# Android
android.useAndroidX=true
# Compatibility with AGP 9.0.0
android.newDsl=false
# It's recommended to add these settings to your $GRADLE_USER_HOME/gradle.properties:
# org.gradle.configuration-cache=true

View File

@@ -1,7 +1,7 @@
# Comments apply to next line
[versions]
android-agp = "8.13.2"
android-agp = "9.0.0"
android-desugaring = "2.1.5"
androidx-activityCompose = "1.12.2"
androidx-appcompat = "1.7.1"
@@ -18,16 +18,16 @@ androidx-test-runner = "1.7.0"
androidx-test-rules = "1.7.0"
androidx-test-junit = "1.3.0"
androidx-work = "2.11.0"
bitfire-cert4android = "42d883e958"
bitfire-cert4android = "75cc6913fd"
bitfire-dav4jvm = "acf8e4ef9b"
bitfire-synctools = "42e82f4769"
bitfire-synctools = "25b92ef99a"
compose-accompanist = "0.37.3"
compose-bom = "2025.12.01"
compose-bom = "2026.01.00"
conscrypt = "2.5.3"
dnsjava = "3.6.3"
dnsjava = "3.6.4"
glance = "1.1.1"
guava = "33.5.0-android"
hilt = "2.57.2"
hilt = "2.59"
# keep in sync with ksp version
kotlin = "2.2.21"
kotlinx-coroutines = "1.10.2"
@@ -120,7 +120,6 @@ unifiedpush-fcm = { module = "org.unifiedpush.android:embedded-fcm-distributor",
android-application = { id = "com.android.application", version.ref = "android-agp" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
mikepenz-aboutLibraries-android = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "mikepenz-aboutLibraries" }

View File

Binary file not shown.

View File

@@ -1,10 +1,6 @@
#
# Copyright <20> All Contributors. See LICENSE and AUTHORS in the root directory for details.
#
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

11
gradlew vendored
View File

@@ -1,7 +1,7 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -86,8 +86,7 @@ done
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@@ -115,7 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
@@ -206,7 +205,7 @@ fi
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
@@ -214,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.

4
gradlew.bat vendored
View File

@@ -70,11 +70,11 @@ goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell

View File

@@ -1,17 +0,0 @@
#!/bin/sh
export TX_TOKEN=`awk '/token *=/ { print $3; }' <$HOME/.transifexrc`
cd `pwd $0`/..
tx pull -a -f --use-git-timestamps
if find app/src -type d -name 'values-*_*' -exec false '{}' +
then
echo "No values-XX_RR directory found, good"
else
echo "Found values-XX_RR directory, update .tx/config mappings to values-XX-rRR!"
exit 1
fi
curl -H "Authorization: Bearer $TX_TOKEN" 'https://rest.api.transifex.com/team_memberships?filter\[organization\]=o:bitfireAT&filter\[team\]=o:bitfireAT:t:davx5-team' \
| scripts/rewrite-translators.rb >app/src/main/assets/translators.json

View File

@@ -1,24 +0,0 @@
#!/usr/bin/ruby
require 'json'
contributors = {}
transifex = JSON.parse(STDIN.read, :symbolize_names => true)
for t in transifex[:data]
raise unless t[:type] == 'team_memberships'
#next unless t[:attributes][:role] == 'translator'
rel = t[:relationships]
lang = rel[:language][:data][:id].delete_prefix('l:')
user = rel[:user][:data][:id].delete_prefix('u:')
next if user == 'bitfire'
contributors[lang] = [] if contributors[lang].nil?
contributors[lang] << user
end
contributors.transform_values! { |u| u.sort }
puts contributors.sort.to_h.to_json