mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2026-01-28 08:41:30 -05:00
Compare commits
19 Commits
v4.5.8-ose
...
reuse-http
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7b2b79477 | ||
|
|
c37781ebf5 | ||
|
|
eacfde096d | ||
|
|
fa99d03a2e | ||
|
|
d642b6e37c | ||
|
|
647be71192 | ||
|
|
218559afb6 | ||
|
|
256b3381c9 | ||
|
|
ee57967152 | ||
|
|
a144180c70 | ||
|
|
47685e6693 | ||
|
|
2c7b36ecd5 | ||
|
|
cf80b11808 | ||
|
|
18649f711a | ||
|
|
377a159e75 | ||
|
|
393d22f720 | ||
|
|
5b12ecf6b6 | ||
|
|
f8f6134640 | ||
|
|
0f7908da23 |
32
.tx/config
32
.tx/config
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
6
gradle/wrapper/gradle-wrapper.properties
vendored
6
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -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
11
gradlew
vendored
@@ -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
4
gradlew.bat
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user