Repo created
This commit is contained in:
parent
324070df30
commit
2d33a757bf
644 changed files with 99721 additions and 2 deletions
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.webdav.Owner
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Principal
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
/**
|
||||
* Logic for refreshing the list of collections (and their related information)
|
||||
* which do not belong to a home set.
|
||||
*/
|
||||
class CollectionsWithoutHomeSetRefresher @AssistedInject constructor(
|
||||
@Assisted private val service: Service,
|
||||
@Assisted private val httpClient: OkHttpClient,
|
||||
private val db: AppDatabase,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(service: Service, httpClient: OkHttpClient): CollectionsWithoutHomeSetRefresher
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes collections which don't have a homeset.
|
||||
*
|
||||
* It queries each stored collection with a homeSetId of "null" and either updates or deletes (if inaccessible or unusable) them.
|
||||
*/
|
||||
internal fun refreshCollectionsWithoutHomeSet() {
|
||||
val withoutHomeSet = db.collectionDao().getByServiceAndHomeset(service.id, null).associateBy { it.url }.toMutableMap()
|
||||
for ((url, localCollection) in withoutHomeSet) try {
|
||||
val collectionProperties = ServiceDetectionUtils.collectionQueryProperties(service.type)
|
||||
DavResource(httpClient, url).propfind(0, *collectionProperties) { response, _ ->
|
||||
if (!response.isSuccess()) {
|
||||
collectionRepository.delete(localCollection)
|
||||
return@propfind
|
||||
}
|
||||
|
||||
// Save or update the collection, if usable, otherwise delete it
|
||||
Collection.fromDavResponse(response)?.let { collection ->
|
||||
if (!ServiceDetectionUtils.isUsableCollection(service, collection))
|
||||
return@let
|
||||
collectionRepository.insertOrUpdateByUrlRememberSync(collection.copy(
|
||||
serviceId = localCollection.serviceId, // use same service ID as previous entry
|
||||
ownerId = response[Owner::class.java]?.href // save the principal id (collection owner)
|
||||
?.let { response.href.resolve(it) }
|
||||
?.let { principalUrl -> Principal.fromServiceAndUrl(service, principalUrl) }
|
||||
?.let { principal -> db.principalDao().insertOrUpdate(service.id, principal) }
|
||||
))
|
||||
} ?: collectionRepository.delete(localCollection)
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
// delete collection locally if it was not accessible (40x)
|
||||
if (e.statusCode in arrayOf(403, 404, 410))
|
||||
collectionRepository.delete(localCollection)
|
||||
else
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,506 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import androidx.core.content.getSystemService
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.exception.UnauthorizedException
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarColor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarHomeSet
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarUserAddressSet
|
||||
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
|
||||
import at.bitfire.dav4jvm.property.common.HrefListProperty
|
||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrincipal
|
||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.log.StringHandler
|
||||
import at.bitfire.davdroid.network.DnsRecordResolver
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.settings.Credentials
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.xbill.DNS.Type
|
||||
import java.io.InterruptedIOException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
import java.util.LinkedList
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* Does initial resource detection when an account is added. It uses the (user given) base URL to find
|
||||
*
|
||||
* - services (CalDAV and/or CardDAV),
|
||||
* - principal,
|
||||
* - homeset/collections (multistatus responses are handled through dav4jvm).
|
||||
*
|
||||
* @param context to build the HTTP client
|
||||
* @param baseURI user-given base URI (either mailto: URI or http(s):// URL)
|
||||
* @param credentials optional login credentials (username/password, client certificate, OAuth state)
|
||||
*/
|
||||
class DavResourceFinder @AssistedInject constructor(
|
||||
@Assisted private val baseURI: URI,
|
||||
@Assisted private val credentials: Credentials? = null,
|
||||
@ApplicationContext val context: Context,
|
||||
private val dnsRecordResolver: DnsRecordResolver,
|
||||
httpClientBuilder: HttpClient.Builder
|
||||
): AutoCloseable {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(baseURI: URI, credentials: Credentials?): DavResourceFinder
|
||||
}
|
||||
|
||||
enum class Service(val wellKnownName: String) {
|
||||
CALDAV("caldav"),
|
||||
CARDDAV("carddav");
|
||||
|
||||
override fun toString() = wellKnownName
|
||||
}
|
||||
|
||||
val log: Logger = Logger.getLogger(javaClass.name)
|
||||
private val logBuffer: StringHandler = initLogging()
|
||||
|
||||
private var encountered401 = false
|
||||
|
||||
private val httpClient = httpClientBuilder
|
||||
.setLogger(log)
|
||||
.apply {
|
||||
if (credentials != null)
|
||||
authenticate(
|
||||
host = null,
|
||||
getCredentials = { credentials }
|
||||
)
|
||||
}
|
||||
.build()
|
||||
|
||||
override fun close() {
|
||||
httpClient.close()
|
||||
}
|
||||
|
||||
private fun initLogging(): StringHandler {
|
||||
// don't use more than 1/4 of the available memory for a log string
|
||||
val activityManager = context.getSystemService<ActivityManager>()!!
|
||||
val maxLogSize = activityManager.memoryClass * (1024 * 1024 / 8)
|
||||
val handler = StringHandler(maxLogSize)
|
||||
|
||||
// add StringHandler to logger
|
||||
log.level = Level.ALL
|
||||
log.addHandler(handler)
|
||||
|
||||
return handler
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Finds the initial configuration (= runs the service detection process).
|
||||
*
|
||||
* In case of an error, it returns an empty [Configuration] with error logs
|
||||
* instead of throwing an [Exception].
|
||||
*
|
||||
* @return service information – if there's neither a CalDAV service nor a CardDAV service,
|
||||
* service detection was not successful
|
||||
*/
|
||||
fun findInitialConfiguration(): Configuration {
|
||||
var cardDavConfig: Configuration.ServiceInfo? = null
|
||||
var calDavConfig: Configuration.ServiceInfo? = null
|
||||
|
||||
try {
|
||||
try {
|
||||
cardDavConfig = findInitialConfiguration(Service.CARDDAV)
|
||||
} catch (e: Exception) {
|
||||
log.log(Level.INFO, "CardDAV service detection failed", e)
|
||||
processException(e)
|
||||
}
|
||||
|
||||
try {
|
||||
calDavConfig = findInitialConfiguration(Service.CALDAV)
|
||||
} catch (e: Exception) {
|
||||
log.log(Level.INFO, "CalDAV service detection failed", e)
|
||||
processException(e)
|
||||
}
|
||||
} catch(_: Exception) {
|
||||
// we have been interrupted; reset results so that an error message will be shown
|
||||
cardDavConfig = null
|
||||
calDavConfig = null
|
||||
}
|
||||
|
||||
return Configuration(
|
||||
cardDAV = cardDavConfig,
|
||||
calDAV = calDavConfig,
|
||||
encountered401 = encountered401,
|
||||
logs = logBuffer.toString()
|
||||
)
|
||||
}
|
||||
|
||||
private fun findInitialConfiguration(service: Service): Configuration.ServiceInfo? {
|
||||
// domain for service discovery
|
||||
var discoveryFQDN: String? = null
|
||||
|
||||
// discovered information goes into this config
|
||||
val config = Configuration.ServiceInfo()
|
||||
|
||||
// Start discovering
|
||||
log.info("Finding initial ${service.wellKnownName} service configuration")
|
||||
when (baseURI.scheme.lowercase()) {
|
||||
"http", "https" ->
|
||||
baseURI.toHttpUrlOrNull()?.let { baseURL ->
|
||||
// remember domain for service discovery
|
||||
if (baseURL.scheme.equals("https", true))
|
||||
// service discovery will only be tried for https URLs, because only secure service discovery is implemented
|
||||
discoveryFQDN = baseURL.host
|
||||
|
||||
// Actual discovery process
|
||||
checkBaseURL(baseURL, service, config)
|
||||
|
||||
// If principal was not found already, try well known URI
|
||||
if (config.principal == null)
|
||||
try {
|
||||
config.principal = getCurrentUserPrincipal(baseURL.resolve("/.well-known/" + service.wellKnownName)!!, service)
|
||||
} catch(e: Exception) {
|
||||
log.log(Level.FINE, "Well-known URL detection failed", e)
|
||||
processException(e)
|
||||
}
|
||||
}
|
||||
"mailto" -> {
|
||||
val mailbox = baseURI.schemeSpecificPart
|
||||
val posAt = mailbox.lastIndexOf("@")
|
||||
if (posAt != -1)
|
||||
discoveryFQDN = mailbox.substring(posAt + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Second try: If user-given URL didn't reveal a principal, search for it (SERVICE DISCOVERY)
|
||||
if (config.principal == null)
|
||||
discoveryFQDN?.let { fqdn ->
|
||||
log.info("No principal found at user-given URL, trying to discover for domain $fqdn")
|
||||
try {
|
||||
config.principal = discoverPrincipalUrl(fqdn, service)
|
||||
} catch(e: Exception) {
|
||||
log.log(Level.FINE, "$service service discovery failed", e)
|
||||
processException(e)
|
||||
}
|
||||
}
|
||||
|
||||
// detect email address
|
||||
if (service == Service.CALDAV)
|
||||
config.principal?.let { principal ->
|
||||
config.emails.addAll(queryEmailAddress(principal))
|
||||
}
|
||||
|
||||
// return config or null if config doesn't contain useful information
|
||||
val serviceAvailable = config.principal != null || config.homeSets.isNotEmpty() || config.collections.isNotEmpty()
|
||||
return if (serviceAvailable)
|
||||
config
|
||||
else
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point of the actual discovery process.
|
||||
*
|
||||
* Queries the user-given URL (= base URL) to detect whether it contains a current-user-principal
|
||||
* or whether it is a homeset or collection.
|
||||
*
|
||||
* @param baseURL base URL provided by the user
|
||||
* @param service service to detect configuration for
|
||||
* @param config found configuration will be written to this object
|
||||
*/
|
||||
private fun checkBaseURL(baseURL: HttpUrl, service: Service, config: Configuration.ServiceInfo) {
|
||||
log.info("Checking user-given URL: $baseURL")
|
||||
|
||||
val davBaseURL = DavResource(httpClient.okHttpClient, baseURL, log)
|
||||
try {
|
||||
when (service) {
|
||||
Service.CARDDAV -> {
|
||||
davBaseURL.propfind(
|
||||
0,
|
||||
ResourceType.NAME, DisplayName.NAME, AddressbookDescription.NAME,
|
||||
AddressbookHomeSet.NAME,
|
||||
CurrentUserPrincipal.NAME
|
||||
) { response, _ ->
|
||||
scanResponse(ResourceType.ADDRESSBOOK, response, config)
|
||||
}
|
||||
}
|
||||
Service.CALDAV -> {
|
||||
davBaseURL.propfind(
|
||||
0,
|
||||
ResourceType.NAME, DisplayName.NAME, CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME, CurrentUserPrivilegeSet.NAME, SupportedCalendarComponentSet.NAME,
|
||||
CalendarHomeSet.NAME,
|
||||
CurrentUserPrincipal.NAME
|
||||
) { response, _ ->
|
||||
scanResponse(ResourceType.CALENDAR, response, config)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
log.log(Level.FINE, "PROPFIND/OPTIONS on user-given URL failed", e)
|
||||
processException(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries a user's email address using CalDAV scheduling: calendar-user-address-set.
|
||||
* @param principal principal URL of the user
|
||||
* @return list of found email addresses (empty if none)
|
||||
*/
|
||||
fun queryEmailAddress(principal: HttpUrl): List<String> {
|
||||
val mailboxes = LinkedList<String>()
|
||||
try {
|
||||
DavResource(httpClient.okHttpClient, principal, log).propfind(0, CalendarUserAddressSet.NAME) { response, _ ->
|
||||
response[CalendarUserAddressSet::class.java]?.let { addressSet ->
|
||||
for (href in addressSet.hrefs)
|
||||
try {
|
||||
val uri = URI(href)
|
||||
if (uri.scheme.equals("mailto", true))
|
||||
mailboxes.add(uri.schemeSpecificPart)
|
||||
} catch(e: URISyntaxException) {
|
||||
log.log(Level.WARNING, "Couldn't parse user address", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
log.log(Level.WARNING, "Couldn't query user email address", e)
|
||||
processException(e)
|
||||
}
|
||||
return mailboxes
|
||||
}
|
||||
|
||||
/**
|
||||
* Depending on [resourceType] (CalDAV or CardDAV), this method checks whether [davResponse] references
|
||||
* - an address book or calendar (actual resource), and/or
|
||||
* - an "address book home set" or a "calendar home set", and/or
|
||||
* - whether it's a principal.
|
||||
*
|
||||
* Respectively, this method will add the response to [config.collections], [config.homesets] and/or [config.principal].
|
||||
* Collection URLs will be stored with trailing "/".
|
||||
*
|
||||
* @param resourceType type of service to search for in the response
|
||||
* @param davResponse response whose properties are evaluated
|
||||
* @param config structure storing the references
|
||||
*/
|
||||
fun scanResponse(resourceType: Property.Name, davResponse: Response, config: Configuration.ServiceInfo) {
|
||||
var principal: HttpUrl? = null
|
||||
|
||||
// Type mapping
|
||||
val homeSetClass: Class<out HrefListProperty>
|
||||
val serviceType: Service
|
||||
when (resourceType) {
|
||||
ResourceType.ADDRESSBOOK -> {
|
||||
homeSetClass = AddressbookHomeSet::class.java
|
||||
serviceType = Service.CARDDAV
|
||||
}
|
||||
ResourceType.CALENDAR -> {
|
||||
homeSetClass = CalendarHomeSet::class.java
|
||||
serviceType = Service.CALDAV
|
||||
}
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
// check for current-user-principal
|
||||
davResponse[CurrentUserPrincipal::class.java]?.href?.let { currentUserPrincipal ->
|
||||
principal = davResponse.requestedUrl.resolve(currentUserPrincipal)
|
||||
}
|
||||
|
||||
davResponse[ResourceType::class.java]?.let {
|
||||
// Is it a calendar or an address book, ...
|
||||
if (it.types.contains(resourceType))
|
||||
Collection.fromDavResponse(davResponse)?.let { info ->
|
||||
log.info("Found resource of type $resourceType at ${info.url}")
|
||||
config.collections[info.url] = info
|
||||
}
|
||||
|
||||
// ... and/or a principal?
|
||||
if (it.types.contains(ResourceType.PRINCIPAL))
|
||||
principal = davResponse.href
|
||||
}
|
||||
|
||||
// Is it an addressbook-home-set or calendar-home-set?
|
||||
davResponse[homeSetClass]?.let { homeSet ->
|
||||
for (href in homeSet.hrefs) {
|
||||
davResponse.requestedUrl.resolve(href)?.let {
|
||||
val location = UrlUtils.withTrailingSlash(it)
|
||||
log.info("Found home-set of type $resourceType at $location")
|
||||
config.homeSets += location
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Is there a principal too?
|
||||
principal?.let {
|
||||
if (providesService(it, serviceType))
|
||||
config.principal = principal
|
||||
else
|
||||
log.warning("Principal $principal doesn't provide $serviceType service")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an OPTIONS request to determine whether a URL provides a given service.
|
||||
*
|
||||
* @param url URL to check; often a principal URL
|
||||
* @param service service to check for
|
||||
*
|
||||
* @return whether the URL provides the given service
|
||||
*/
|
||||
fun providesService(url: HttpUrl, service: Service): Boolean {
|
||||
var provided = false
|
||||
try {
|
||||
DavResource(httpClient.okHttpClient, url, log).options { capabilities, _ ->
|
||||
if ((service == Service.CARDDAV && capabilities.contains("addressbook")) ||
|
||||
(service == Service.CALDAV && capabilities.contains("calendar-access")))
|
||||
provided = true
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
log.log(Level.SEVERE, "Couldn't detect services on $url", e)
|
||||
if (e !is HttpException && e !is DavException)
|
||||
throw e
|
||||
}
|
||||
return provided
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Try to find the principal URL by performing service discovery on a given domain name.
|
||||
* Only secure services (caldavs, carddavs) will be discovered!
|
||||
*
|
||||
* @param domain domain name, e.g. "icloud.com"
|
||||
* @param service service to discover (CALDAV or CARDDAV)
|
||||
* @return principal URL, or null if none found
|
||||
*/
|
||||
fun discoverPrincipalUrl(domain: String, service: Service): HttpUrl? {
|
||||
val scheme: String
|
||||
val fqdn: String
|
||||
var port = 443
|
||||
val paths = LinkedList<String>() // there may be multiple paths to try
|
||||
|
||||
val query = "_${service.wellKnownName}s._tcp.$domain"
|
||||
log.fine("Looking up SRV records for $query")
|
||||
|
||||
val srvRecords = dnsRecordResolver.resolve(query, Type.SRV)
|
||||
val srv = dnsRecordResolver.bestSRVRecord(srvRecords)
|
||||
|
||||
if (srv != null) {
|
||||
// choose SRV record to use (query may return multiple SRV records)
|
||||
scheme = "https"
|
||||
fqdn = srv.target.toString(true)
|
||||
port = srv.port
|
||||
log.info("Found $service service at https://$fqdn:$port")
|
||||
} else {
|
||||
// no SRV records, try domain name as FQDN
|
||||
log.info("Didn't find $service service, trying at https://$domain:$port")
|
||||
|
||||
scheme = "https"
|
||||
fqdn = domain
|
||||
}
|
||||
|
||||
// look for TXT record too (for initial context path)
|
||||
val txtRecords = dnsRecordResolver.resolve(query, Type.TXT)
|
||||
paths.addAll(dnsRecordResolver.pathsFromTXTRecords(txtRecords))
|
||||
|
||||
// in case there's a TXT record, but it's wrong, try well-known
|
||||
paths.add("/.well-known/" + service.wellKnownName)
|
||||
// if this fails too, try "/"
|
||||
paths.add("/")
|
||||
|
||||
for (path in paths)
|
||||
try {
|
||||
val initialContextPath = HttpUrl.Builder()
|
||||
.scheme(scheme)
|
||||
.host(fqdn).port(port)
|
||||
.encodedPath(path)
|
||||
.build()
|
||||
|
||||
log.info("Trying to determine principal from initial context path=$initialContextPath")
|
||||
val principal = getCurrentUserPrincipal(initialContextPath, service)
|
||||
|
||||
principal?.let { return it }
|
||||
} catch(e: Exception) {
|
||||
log.log(Level.WARNING, "No resource found", e)
|
||||
processException(e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries a given URL for current-user-principal
|
||||
*
|
||||
* @param url URL to query with PROPFIND (Depth: 0)
|
||||
* @param service required service (may be null, in which case no service check is done)
|
||||
* @return current-user-principal URL that provides required service, or null if none
|
||||
*/
|
||||
fun getCurrentUserPrincipal(url: HttpUrl, service: Service?): HttpUrl? {
|
||||
var principal: HttpUrl? = null
|
||||
DavResource(httpClient.okHttpClient, url, log).propfind(0, CurrentUserPrincipal.NAME) { response, _ ->
|
||||
response[CurrentUserPrincipal::class.java]?.href?.let { href ->
|
||||
response.requestedUrl.resolve(href)?.let {
|
||||
log.info("Found current-user-principal: $it")
|
||||
|
||||
// service check
|
||||
if (service != null && !providesService(it, service))
|
||||
log.warning("Principal $it doesn't provide $service service")
|
||||
else
|
||||
principal = it
|
||||
}
|
||||
}
|
||||
}
|
||||
return principal
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a thrown exception like this:
|
||||
*
|
||||
* - If the Exception is an [UnauthorizedException] (HTTP 401), [encountered401] is set to *true*.
|
||||
* - Re-throws the exception if it signals that the current thread was interrupted to stop the current operation.
|
||||
*/
|
||||
private fun processException(e: Exception) {
|
||||
if (e is UnauthorizedException)
|
||||
encountered401 = true
|
||||
else if ((e is InterruptedIOException && e !is SocketTimeoutException) || e is InterruptedException)
|
||||
throw e
|
||||
}
|
||||
|
||||
|
||||
// data classes
|
||||
|
||||
class Configuration(
|
||||
val cardDAV: ServiceInfo?,
|
||||
val calDAV: ServiceInfo?,
|
||||
|
||||
val encountered401: Boolean,
|
||||
val logs: String
|
||||
) {
|
||||
|
||||
data class ServiceInfo(
|
||||
var principal: HttpUrl? = null,
|
||||
val homeSets: MutableSet<HttpUrl> = HashSet(),
|
||||
val collections: MutableMap<HttpUrl, Collection> = HashMap(),
|
||||
|
||||
val emails: MutableList<String> = LinkedList()
|
||||
)
|
||||
|
||||
override fun toString() =
|
||||
"DavResourceFinder.Configuration(cardDAV=$cardDAV, calDAV=$calDAV, encountered401=$encountered401, logs=(${logs.length} chars))"
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.Owner
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.HomeSet
|
||||
import at.bitfire.davdroid.db.Principal
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavHomeSetRepository
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* Used to update the list of synchronizable collections
|
||||
*/
|
||||
class HomeSetRefresher @AssistedInject constructor(
|
||||
@Assisted private val service: Service,
|
||||
@Assisted private val httpClient: OkHttpClient,
|
||||
private val db: AppDatabase,
|
||||
private val logger: Logger,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
private val homeSetRepository: DavHomeSetRepository,
|
||||
private val settings: SettingsManager
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(service: Service, httpClient: OkHttpClient): HomeSetRefresher
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes home-sets and their collections.
|
||||
*
|
||||
* Each stored home-set URL is queried (`PROPFIND`) and its collections are either saved, updated
|
||||
* or marked as "without home-set" - in case a collection was removed from its home-set.
|
||||
*
|
||||
* If a home-set URL in fact points to a collection directly, the collection will be saved with this URL,
|
||||
* and a null value for it's home-set. Refreshing of collections without home-sets is then handled by [CollectionsWithoutHomeSetRefresher.refreshCollectionsWithoutHomeSet].
|
||||
*/
|
||||
internal fun refreshHomesetsAndTheirCollections() {
|
||||
val homesets = homeSetRepository.getByServiceBlocking(service.id).associateBy { it.url }.toMutableMap()
|
||||
for ((homeSetUrl, localHomeset) in homesets) {
|
||||
logger.fine("Listing home set $homeSetUrl")
|
||||
|
||||
// To find removed collections in this homeset: create a queue from existing collections and remove every collection that
|
||||
// is successfully rediscovered. If there are collections left, after processing is done, these are marked as "without home-set".
|
||||
val localHomesetCollections = db.collectionDao()
|
||||
.getByServiceAndHomeset(service.id, localHomeset.id)
|
||||
.associateBy { it.url }
|
||||
.toMutableMap()
|
||||
|
||||
try {
|
||||
val collectionProperties = ServiceDetectionUtils.collectionQueryProperties(service.type)
|
||||
DavResource(httpClient, homeSetUrl).propfind(1, *collectionProperties) { response, relation ->
|
||||
// Note: This callback may be called multiple times ([MultiResponseCallback])
|
||||
if (!response.isSuccess())
|
||||
return@propfind
|
||||
|
||||
if (relation == Response.HrefRelation.SELF)
|
||||
// this response is about the home set itself
|
||||
homeSetRepository.insertOrUpdateByUrlBlocking(
|
||||
localHomeset.copy(
|
||||
displayName = response[DisplayName::class.java]?.displayName,
|
||||
privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind != false
|
||||
)
|
||||
)
|
||||
|
||||
// in any case, check whether the response is about a usable collection
|
||||
var collection = Collection.fromDavResponse(response) ?: return@propfind
|
||||
collection = collection.copy(
|
||||
serviceId = service.id,
|
||||
homeSetId = localHomeset.id,
|
||||
sync = shouldPreselect(collection, homesets.values),
|
||||
ownerId = response[Owner::class.java]?.href // save the principal id (collection owner)
|
||||
?.let { response.href.resolve(it) }
|
||||
?.let { principalUrl -> Principal.fromServiceAndUrl(service, principalUrl) }
|
||||
?.let { principal -> db.principalDao().insertOrUpdate(service.id, principal) }
|
||||
)
|
||||
logger.log(Level.FINE, "Found collection", collection)
|
||||
|
||||
// save or update collection if usable (ignore it otherwise)
|
||||
if (ServiceDetectionUtils.isUsableCollection(service, collection))
|
||||
collectionRepository.insertOrUpdateByUrlRememberSync(collection)
|
||||
|
||||
// Remove this collection from queue - because it was found in the home set
|
||||
localHomesetCollections.remove(collection.url)
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
// delete home set locally if it was not accessible (40x)
|
||||
if (e.statusCode in arrayOf(403, 404, 410))
|
||||
homeSetRepository.deleteBlocking(localHomeset)
|
||||
}
|
||||
|
||||
// Mark leftover (not rediscovered) collections from queue as "without home-set" (remove association)
|
||||
for ((_, collection) in localHomesetCollections)
|
||||
collectionRepository.insertOrUpdateByUrlRememberSync(
|
||||
collection.copy(homeSetId = null)
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to preselect the given collection for synchronisation, according to the
|
||||
* settings [Settings.PRESELECT_COLLECTIONS] (see there for allowed values) and
|
||||
* [Settings.PRESELECT_COLLECTIONS_EXCLUDED].
|
||||
*
|
||||
* A collection is considered _personal_ if it is found in one of the current-user-principal's home-sets.
|
||||
*
|
||||
* Before a collection is pre-selected, we check whether its URL matches the regexp in
|
||||
* [Settings.PRESELECT_COLLECTIONS_EXCLUDED], in which case *false* is returned.
|
||||
*
|
||||
* @param collection the collection to check
|
||||
* @param homeSets list of personal home-sets
|
||||
* @return *true* if the collection should be preselected for synchronization; *false* otherwise
|
||||
*/
|
||||
internal fun shouldPreselect(collection: Collection, homeSets: Iterable<HomeSet>): Boolean {
|
||||
val shouldPreselect = settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS)
|
||||
|
||||
val excluded by lazy {
|
||||
val excludedRegex = settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED)
|
||||
if (!excludedRegex.isNullOrEmpty())
|
||||
Regex(excludedRegex).containsMatchIn(collection.url.toString())
|
||||
else
|
||||
false
|
||||
}
|
||||
|
||||
return when (shouldPreselect) {
|
||||
Settings.PRESELECT_COLLECTIONS_ALL ->
|
||||
// preselect if collection url is not excluded
|
||||
!excluded
|
||||
|
||||
Settings.PRESELECT_COLLECTIONS_PERSONAL ->
|
||||
// preselect if is personal (in a personal home-set), but not excluded
|
||||
homeSets
|
||||
.filter { homeset -> homeset.personal }
|
||||
.map { homeset -> homeset.id }
|
||||
.contains(collection.homeSetId)
|
||||
&& !excluded
|
||||
|
||||
else -> // don't preselect
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Principal
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* Used to update the principals (their current display names) and delete those without collections.
|
||||
*/
|
||||
class PrincipalsRefresher @AssistedInject constructor(
|
||||
@Assisted private val service: Service,
|
||||
@Assisted private val httpClient: OkHttpClient,
|
||||
private val db: AppDatabase,
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(service: Service, httpClient: OkHttpClient): PrincipalsRefresher
|
||||
}
|
||||
|
||||
/**
|
||||
* Principal properties to ask the server for.
|
||||
*/
|
||||
private val principalProperties = arrayOf(
|
||||
DisplayName.NAME,
|
||||
ResourceType.NAME
|
||||
)
|
||||
|
||||
/**
|
||||
* Refreshes the principals (get their current display names).
|
||||
* Also removes principals which do not own any collections anymore.
|
||||
*/
|
||||
fun refreshPrincipals() {
|
||||
// Refresh principals (collection owner urls)
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
for (oldPrincipal in principals) {
|
||||
val principalUrl = oldPrincipal.url
|
||||
logger.fine("Querying principal $principalUrl")
|
||||
try {
|
||||
DavResource(httpClient, principalUrl).propfind(0, *principalProperties) { response, _ ->
|
||||
if (!response.isSuccess())
|
||||
return@propfind
|
||||
Principal.fromDavResponse(service.id, response)?.let { principal ->
|
||||
logger.fine("Got principal: $principal")
|
||||
db.principalDao().insertOrUpdate(service.id, principal)
|
||||
}
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
logger.info("Principal update failed with response code ${e.statusCode}. principalUrl=$principalUrl")
|
||||
}
|
||||
}
|
||||
|
||||
// Delete principals which don't own any collections
|
||||
db.principalDao().getAllWithoutCollections().forEach { principal ->
|
||||
db.principalDao().delete(principal)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.TaskStackBuilder
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.Operation
|
||||
import androidx.work.OutOfQuotaPolicy
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import at.bitfire.dav4jvm.exception.UnauthorizedException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.push.PushRegistrationManager
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker.Companion.ARG_SERVICE_ID
|
||||
import at.bitfire.davdroid.sync.account.InvalidAccountException
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity
|
||||
import at.bitfire.davdroid.ui.NotificationRegistry
|
||||
import at.bitfire.davdroid.ui.account.AccountSettingsActivity
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* Refreshes list of home sets and their respective collections of a service type (CardDAV or CalDAV).
|
||||
* Called from UI, when user wants to refresh all collections of a service.
|
||||
*
|
||||
* Input data:
|
||||
*
|
||||
* - [ARG_SERVICE_ID]: service ID
|
||||
*
|
||||
* It queries all existing homesets and/or collections and then:
|
||||
* - updates resources with found properties (overwrites without comparing)
|
||||
* - adds resources if new ones are detected
|
||||
* - removes resources if not found 40x (delete locally)
|
||||
*
|
||||
* Expedited: yes (always initiated by user)
|
||||
*
|
||||
* Long-running: no
|
||||
*
|
||||
* @throws IllegalArgumentException when there's no service with the given service ID
|
||||
*/
|
||||
@HiltWorker
|
||||
class RefreshCollectionsWorker @AssistedInject constructor(
|
||||
@Assisted appContext: Context,
|
||||
@Assisted workerParams: WorkerParameters,
|
||||
private val collectionsWithoutHomeSetRefresherFactory: CollectionsWithoutHomeSetRefresher.Factory,
|
||||
private val homeSetRefresherFactory: HomeSetRefresher.Factory,
|
||||
private val httpClientBuilder: HttpClient.Builder,
|
||||
private val logger: Logger,
|
||||
private val notificationRegistry: NotificationRegistry,
|
||||
private val principalsRefresherFactory: PrincipalsRefresher.Factory,
|
||||
private val pushRegistrationManager: PushRegistrationManager,
|
||||
private val serviceRefresherFactory: ServiceRefresher.Factory,
|
||||
serviceRepository: DavServiceRepository
|
||||
): CoroutineWorker(appContext, workerParams) {
|
||||
|
||||
companion object {
|
||||
|
||||
const val ARG_SERVICE_ID = "serviceId"
|
||||
const val WORKER_TAG = "refreshCollectionsWorker"
|
||||
|
||||
/**
|
||||
* Uniquely identifies a refresh worker. Useful for stopping work, or querying its state.
|
||||
*
|
||||
* @param serviceId what service (CalDAV/CardDAV) the worker is running for
|
||||
*/
|
||||
fun workerName(serviceId: Long): String = "$WORKER_TAG-$serviceId"
|
||||
|
||||
/**
|
||||
* Requests immediate refresh of a given service. If not running already. this will enqueue
|
||||
* a [RefreshCollectionsWorker].
|
||||
*
|
||||
* @param serviceId serviceId which is to be refreshed
|
||||
* @return Pair with
|
||||
*
|
||||
* 1. worker name,
|
||||
* 2. operation of [WorkManager.enqueueUniqueWork] (can be used to wait for completion)
|
||||
*
|
||||
* @throws IllegalArgumentException when there's no service with this ID
|
||||
*/
|
||||
fun enqueue(context: Context, serviceId: Long): Pair<String, Operation> {
|
||||
val name = workerName(serviceId)
|
||||
val arguments = Data.Builder()
|
||||
.putLong(ARG_SERVICE_ID, serviceId)
|
||||
.build()
|
||||
val workRequest = OneTimeWorkRequestBuilder<RefreshCollectionsWorker>()
|
||||
.addTag(name)
|
||||
.setInputData(arguments)
|
||||
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||
.build()
|
||||
|
||||
return Pair(
|
||||
name,
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
name,
|
||||
ExistingWorkPolicy.KEEP, // if refresh is already running, just continue that one
|
||||
workRequest
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Observes whether a refresh worker with given service id and state exists.
|
||||
*
|
||||
* @param workerName name of worker to find
|
||||
* @param workState state of worker to match
|
||||
*
|
||||
* @return flow that emits `true` if worker with matching state was found (otherwise `false`)
|
||||
*/
|
||||
fun existsFlow(context: Context, workerName: String, workState: WorkInfo.State = WorkInfo.State.RUNNING) =
|
||||
WorkManager.getInstance(context).getWorkInfosForUniqueWorkFlow(workerName).map { workInfoList ->
|
||||
workInfoList.any { workInfo -> workInfo.state == workState }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
val serviceId: Long = inputData.getLong(ARG_SERVICE_ID, -1)
|
||||
val service = serviceRepository.getBlocking(serviceId)
|
||||
val account = service?.let { service ->
|
||||
Account(service.accountName, applicationContext.getString(R.string.account_type))
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
if (service == null || account == null) {
|
||||
logger.warning("Missing service or account with service ID: $serviceId")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info("Refreshing ${service.type} collections of service #$service")
|
||||
|
||||
// cancel previous notification
|
||||
NotificationManagerCompat.from(applicationContext)
|
||||
.cancel(serviceId.toString(), NotificationRegistry.NOTIFY_REFRESH_COLLECTIONS)
|
||||
|
||||
// create authenticating OkHttpClient (credentials taken from account settings)
|
||||
httpClientBuilder
|
||||
.fromAccount(account)
|
||||
.build()
|
||||
.use { httpClient ->
|
||||
runInterruptible {
|
||||
val httpClient = httpClient.okHttpClient
|
||||
val refresher = collectionsWithoutHomeSetRefresherFactory.create(service, httpClient)
|
||||
|
||||
// refresh home set list (from principal url)
|
||||
service.principal?.let { principalUrl ->
|
||||
logger.fine("Querying principal $principalUrl for home sets")
|
||||
val serviceRefresher = serviceRefresherFactory.create(service, httpClient)
|
||||
serviceRefresher.discoverHomesets(principalUrl)
|
||||
}
|
||||
|
||||
// refresh home sets and their member collections
|
||||
homeSetRefresherFactory.create(service, httpClient)
|
||||
.refreshHomesetsAndTheirCollections()
|
||||
|
||||
// also refresh collections without a home set
|
||||
refresher.refreshCollectionsWithoutHomeSet()
|
||||
|
||||
// Lastly, refresh the principals (collection owners)
|
||||
val principalsRefresher = principalsRefresherFactory.create(service, httpClient)
|
||||
principalsRefresher.refreshPrincipals()
|
||||
}
|
||||
}
|
||||
|
||||
} catch(e: InvalidAccountException) {
|
||||
logger.log(Level.SEVERE, "Invalid account", e)
|
||||
return Result.failure()
|
||||
} catch (e: UnauthorizedException) {
|
||||
logger.log(Level.SEVERE, "Not authorized (anymore)", e)
|
||||
// notify that we need to re-authenticate in the account settings
|
||||
val settingsIntent = Intent(applicationContext, AccountSettingsActivity::class.java)
|
||||
.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account)
|
||||
notifyRefreshError(
|
||||
applicationContext.getString(R.string.sync_error_authentication_failed),
|
||||
settingsIntent
|
||||
)
|
||||
return Result.failure()
|
||||
} catch(e: Exception) {
|
||||
logger.log(Level.SEVERE, "Couldn't refresh collection list", e)
|
||||
|
||||
val debugIntent = DebugInfoActivity.IntentBuilder(applicationContext)
|
||||
.withCause(e)
|
||||
.withAccount(account)
|
||||
.build()
|
||||
notifyRefreshError(
|
||||
applicationContext.getString(R.string.refresh_collections_worker_refresh_couldnt_refresh),
|
||||
debugIntent
|
||||
)
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
// update push registrations
|
||||
pushRegistrationManager.update(serviceId)
|
||||
|
||||
// Success
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
/**
|
||||
* Used by WorkManager to show a foreground service notification for expedited jobs on Android <12.
|
||||
*/
|
||||
override suspend fun getForegroundInfo(): ForegroundInfo {
|
||||
val notification = NotificationCompat.Builder(applicationContext, notificationRegistry.CHANNEL_STATUS)
|
||||
.setSmallIcon(R.drawable.ic_foreground_notify)
|
||||
.setContentTitle(applicationContext.getString(R.string.foreground_service_notify_title))
|
||||
.setContentText(applicationContext.getString(R.string.foreground_service_notify_text))
|
||||
.setStyle(NotificationCompat.BigTextStyle())
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setOngoing(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
return ForegroundInfo(NotificationRegistry.NOTIFY_SYNC_EXPEDITED, notification)
|
||||
}
|
||||
|
||||
private fun notifyRefreshError(contentText: String, contentIntent: Intent) {
|
||||
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_REFRESH_COLLECTIONS, tag = serviceId.toString()) {
|
||||
NotificationCompat.Builder(applicationContext, notificationRegistry.CHANNEL_GENERAL)
|
||||
.setSmallIcon(R.drawable.ic_sync_problem_notify)
|
||||
.setContentTitle(applicationContext.getString(R.string.refresh_collections_worker_refresh_failed))
|
||||
.setContentText(contentText)
|
||||
.setContentIntent(
|
||||
TaskStackBuilder.create(applicationContext)
|
||||
.addNextIntentWithParentStack(contentIntent)
|
||||
.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
)
|
||||
.setSubText(account?.name)
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarColor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezoneId
|
||||
import at.bitfire.dav4jvm.property.caldav.Source
|
||||
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
|
||||
import at.bitfire.dav4jvm.property.push.PushTransports
|
||||
import at.bitfire.dav4jvm.property.push.Topic
|
||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.Owner
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.db.ServiceType
|
||||
|
||||
object ServiceDetectionUtils {
|
||||
|
||||
/**
|
||||
* WebDAV properties to ask for in a PROPFIND request on a collection.
|
||||
*/
|
||||
fun collectionQueryProperties(@ServiceType serviceType: String): Array<Property.Name> =
|
||||
arrayOf( // generic WebDAV properties
|
||||
CurrentUserPrivilegeSet.NAME,
|
||||
DisplayName.NAME,
|
||||
Owner.NAME,
|
||||
ResourceType.NAME,
|
||||
PushTransports.NAME, // WebDAV-Push
|
||||
Topic.NAME
|
||||
) + when (serviceType) { // service-specific CalDAV/CardDAV properties
|
||||
Service.TYPE_CARDDAV -> arrayOf(
|
||||
AddressbookDescription.NAME
|
||||
)
|
||||
|
||||
Service.TYPE_CALDAV -> arrayOf(
|
||||
CalendarColor.NAME,
|
||||
CalendarDescription.NAME,
|
||||
CalendarTimezone.NAME,
|
||||
CalendarTimezoneId.NAME,
|
||||
SupportedCalendarComponentSet.NAME,
|
||||
Source.NAME
|
||||
)
|
||||
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds out whether given collection is usable for synchronization, by checking that either
|
||||
*
|
||||
* - CalDAV/CardDAV: service and collection type match, or
|
||||
* - WebCal: subscription source URL is not empty.
|
||||
*/
|
||||
fun isUsableCollection(service: Service, collection: Collection) =
|
||||
(service.type == Service.TYPE_CARDDAV && collection.type == Collection.TYPE_ADDRESSBOOK) ||
|
||||
(service.type == Service.TYPE_CALDAV && arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(collection.type)) ||
|
||||
(collection.type == Collection.TYPE_WEBCAL && collection.source != null)
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarHomeSet
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarProxyReadFor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarProxyWriteFor
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
|
||||
import at.bitfire.dav4jvm.property.common.HrefListProperty
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.GroupMembership
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.db.HomeSet
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.repository.DavHomeSetRepository
|
||||
import at.bitfire.davdroid.util.DavUtils.parent
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* ServiceRefresher is used to discover and save home sets of a given service.
|
||||
*/
|
||||
class ServiceRefresher @AssistedInject constructor(
|
||||
@Assisted private val service: Service,
|
||||
@Assisted private val httpClient: OkHttpClient,
|
||||
private val logger: Logger,
|
||||
private val homeSetRepository: DavHomeSetRepository
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(service: Service, httpClient: OkHttpClient): ServiceRefresher
|
||||
}
|
||||
|
||||
/**
|
||||
* Home-set class to use depending on the given service type.
|
||||
*/
|
||||
private val homeSetClass: Class<out HrefListProperty> =
|
||||
when (service.type) {
|
||||
Service.TYPE_CARDDAV -> AddressbookHomeSet::class.java
|
||||
Service.TYPE_CALDAV -> CalendarHomeSet::class.java
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
/**
|
||||
* Home-set properties to ask for in a PROPFIND request to the principal URL,
|
||||
* depending on the given service type.
|
||||
*/
|
||||
private val homeSetProperties: Array<Property.Name> =
|
||||
arrayOf( // generic WebDAV properties
|
||||
DisplayName.NAME,
|
||||
GroupMembership.NAME,
|
||||
ResourceType.NAME
|
||||
) + when (service.type) { // service-specific CalDAV/CardDAV properties
|
||||
Service.TYPE_CARDDAV -> arrayOf(
|
||||
AddressbookHomeSet.NAME,
|
||||
)
|
||||
|
||||
Service.TYPE_CALDAV -> arrayOf(
|
||||
CalendarHomeSet.NAME,
|
||||
CalendarProxyReadFor.NAME,
|
||||
CalendarProxyWriteFor.NAME
|
||||
)
|
||||
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
/**
|
||||
* Starting at given principal URL, tries to recursively find and save all user relevant home sets.
|
||||
*
|
||||
* @param principalUrl URL of principal to query (user-provided principal or current-user-principal)
|
||||
* @param level Current recursion level (limited to 0, 1 or 2):
|
||||
* - 0: We assume found home sets belong to the current-user-principal
|
||||
* - 1 or 2: We assume found home sets don't directly belong to the current-user-principal
|
||||
* @param alreadyQueriedPrincipals The HttpUrls of principals which have been queried already, to avoid querying principals more than once.
|
||||
* @param alreadySavedHomeSets The HttpUrls of home sets which have been saved to database already, to avoid saving home sets
|
||||
* more than once, which could overwrite the already set "personal" flag with `false`.
|
||||
*
|
||||
* @throws java.io.IOException on I/O errors
|
||||
* @throws HttpException on HTTP errors
|
||||
* @throws at.bitfire.dav4jvm.exception.DavException on application-level or logical errors
|
||||
*/
|
||||
internal fun discoverHomesets(
|
||||
principalUrl: HttpUrl,
|
||||
level: Int = 0,
|
||||
alreadyQueriedPrincipals: MutableSet<HttpUrl> = mutableSetOf(),
|
||||
alreadySavedHomeSets: MutableSet<HttpUrl> = mutableSetOf()
|
||||
) {
|
||||
logger.fine("Discovering homesets of $principalUrl")
|
||||
val relatedResources = mutableSetOf<HttpUrl>()
|
||||
|
||||
// Query the URL
|
||||
val principal = DavResource(httpClient, principalUrl)
|
||||
val personal = level == 0
|
||||
try {
|
||||
principal.propfind(0, *homeSetProperties) { davResponse, _ ->
|
||||
alreadyQueriedPrincipals += davResponse.href
|
||||
|
||||
// If response holds home sets, save them
|
||||
davResponse[homeSetClass]?.let { homeSets ->
|
||||
for (homeSetHref in homeSets.hrefs)
|
||||
principal.location.resolve(homeSetHref)?.let { homesetUrl ->
|
||||
val resolvedHomeSetUrl = UrlUtils.withTrailingSlash(homesetUrl)
|
||||
if (!alreadySavedHomeSets.contains(resolvedHomeSetUrl)) {
|
||||
homeSetRepository.insertOrUpdateByUrlBlocking(
|
||||
// HomeSet is considered personal if this is the outer recursion call,
|
||||
// This is because we assume the first call to query the current-user-principal
|
||||
// Note: This is not be be confused with the DAV:owner attribute. Home sets can be owned by
|
||||
// other principals while still being considered "personal" (belonging to the current-user-principal)
|
||||
// and an owned home set need not always be personal either.
|
||||
HomeSet(0, service.id, personal, resolvedHomeSetUrl)
|
||||
)
|
||||
alreadySavedHomeSets += resolvedHomeSetUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add related principals to be queried afterwards
|
||||
if (personal) {
|
||||
val relatedResourcesTypes = listOf(
|
||||
// current resource is a read/write-proxy for other principals
|
||||
CalendarProxyReadFor::class.java,
|
||||
CalendarProxyWriteFor::class.java,
|
||||
// current resource is a member of a group (principal that can also have proxies)
|
||||
GroupMembership::class.java
|
||||
)
|
||||
for (type in relatedResourcesTypes)
|
||||
davResponse[type]?.let {
|
||||
for (href in it.hrefs)
|
||||
principal.location.resolve(href)?.let { url ->
|
||||
relatedResources += url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If current resource is a calendar-proxy-read/write, it's likely that its parent is a principal, too.
|
||||
davResponse[ResourceType::class.java]?.let { resourceType ->
|
||||
val proxyProperties = arrayOf(
|
||||
ResourceType.CALENDAR_PROXY_READ,
|
||||
ResourceType.CALENDAR_PROXY_WRITE,
|
||||
)
|
||||
if (proxyProperties.any { resourceType.types.contains(it) })
|
||||
relatedResources += davResponse.href.parent()
|
||||
}
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
if (e.isClientError)
|
||||
logger.log(Level.INFO, "Ignoring Client Error 4xx while looking for ${service.type} home sets", e)
|
||||
else
|
||||
throw e
|
||||
}
|
||||
|
||||
// query related resources
|
||||
if (level <= 1)
|
||||
for (resource in relatedResources)
|
||||
if (alreadyQueriedPrincipals.contains(resource))
|
||||
logger.warning("$resource already queried, skipping")
|
||||
else
|
||||
discoverHomesets(
|
||||
principalUrl = resource,
|
||||
level = level + 1,
|
||||
alreadyQueriedPrincipals = alreadyQueriedPrincipals,
|
||||
alreadySavedHomeSets = alreadySavedHomeSets
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue