mirror of https://github.com/tasks/tasks
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
356 lines
14 KiB
Kotlin
356 lines
14 KiB
Kotlin
package org.tasks.caldav
|
|
|
|
import at.bitfire.dav4jvm.DavCollection
|
|
import at.bitfire.dav4jvm.DavResource
|
|
import at.bitfire.dav4jvm.DavResource.Companion.MIME_XML
|
|
import at.bitfire.dav4jvm.Property
|
|
import at.bitfire.dav4jvm.Response
|
|
import at.bitfire.dav4jvm.Response.HrefRelation
|
|
import at.bitfire.dav4jvm.XmlUtils.NS_APPLE_ICAL
|
|
import at.bitfire.dav4jvm.XmlUtils.NS_CALDAV
|
|
import at.bitfire.dav4jvm.XmlUtils.NS_WEBDAV
|
|
import at.bitfire.dav4jvm.exception.DavException
|
|
import at.bitfire.dav4jvm.exception.HttpException
|
|
import at.bitfire.dav4jvm.property.CalendarColor
|
|
import at.bitfire.dav4jvm.property.CalendarHomeSet
|
|
import at.bitfire.dav4jvm.property.CurrentUserPrincipal
|
|
import at.bitfire.dav4jvm.property.CurrentUserPrivilegeSet
|
|
import at.bitfire.dav4jvm.property.DisplayName
|
|
import at.bitfire.dav4jvm.property.GetCTag
|
|
import at.bitfire.dav4jvm.property.ResourceType
|
|
import at.bitfire.dav4jvm.property.ResourceType.Companion.CALENDAR
|
|
import at.bitfire.dav4jvm.property.SupportedCalendarComponentSet
|
|
import at.bitfire.dav4jvm.property.SyncToken
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.withContext
|
|
import okhttp3.HttpUrl
|
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
|
import okhttp3.MediaType.Companion.toMediaType
|
|
import okhttp3.OkHttpClient
|
|
import okhttp3.RequestBody.Companion.toRequestBody
|
|
import org.tasks.R
|
|
import org.tasks.Strings.isNullOrEmpty
|
|
import org.tasks.caldav.property.CalendarIcon
|
|
import org.tasks.caldav.property.Invite
|
|
import org.tasks.caldav.property.OCInvite
|
|
import org.tasks.caldav.property.OCOwnerPrincipal
|
|
import org.tasks.caldav.property.PropertyUtils.NS_OWNCLOUD
|
|
import org.tasks.caldav.property.ShareAccess
|
|
import org.tasks.data.UUIDHelper
|
|
import org.tasks.data.entity.CaldavAccount
|
|
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_NEXTCLOUD
|
|
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_OWNCLOUD
|
|
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_SABREDAV
|
|
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_TASKS
|
|
import org.tasks.data.entity.CaldavCalendar
|
|
import org.tasks.ui.DisplayableException
|
|
import org.xmlpull.v1.XmlPullParserException
|
|
import org.xmlpull.v1.XmlPullParserFactory
|
|
import org.xmlpull.v1.XmlSerializer
|
|
import timber.log.Timber
|
|
import java.io.IOException
|
|
import java.io.StringWriter
|
|
import java.security.KeyManagementException
|
|
import java.security.NoSuchAlgorithmException
|
|
import kotlin.coroutines.suspendCoroutine
|
|
|
|
open class CaldavClient(
|
|
private val provider: CaldavClientProvider,
|
|
val httpClient: OkHttpClient,
|
|
private val httpUrl: HttpUrl?
|
|
) {
|
|
@Throws(NoSuchAlgorithmException::class, KeyManagementException::class)
|
|
suspend fun forAccount(account: CaldavAccount) =
|
|
provider.forAccount(account)
|
|
|
|
private suspend fun tryFindPrincipal(link: String): String? =
|
|
httpUrl
|
|
?.resolve(link)
|
|
?.let { DavResource(httpClient, it) }
|
|
?.propfind(0, CurrentUserPrincipal.NAME)
|
|
?.firstOrNull()
|
|
?.let { (response, _) -> response[CurrentUserPrincipal::class.java] }
|
|
?.href
|
|
?.takeIf { it.isNotBlank() }
|
|
|
|
private suspend fun findHomeset(): String {
|
|
val davResource = DavResource(httpClient, httpUrl!!)
|
|
return davResource
|
|
.propfind(0, CalendarHomeSet.NAME)
|
|
.firstOrNull()
|
|
?.let { (response, _) -> response[CalendarHomeSet::class.java] }
|
|
?.href
|
|
?.takeIf { it.isNotBlank() }
|
|
?.let { davResource.location.resolve(it).toString() }
|
|
?: throw DisplayableException(R.string.caldav_home_set_not_found)
|
|
}
|
|
|
|
@Throws(IOException::class, DavException::class, NoSuchAlgorithmException::class, KeyManagementException::class)
|
|
suspend fun homeSet(
|
|
username: String? = null,
|
|
password: String? = null
|
|
): String = withContext(Dispatchers.IO) {
|
|
var principal: String? = null
|
|
try {
|
|
principal = tryFindPrincipal("/.well-known/caldav")
|
|
} catch (e: Exception) {
|
|
if (e is HttpException && e.code == 401) {
|
|
throw e
|
|
}
|
|
Timber.w(e)
|
|
}
|
|
if (principal == null) {
|
|
principal = tryFindPrincipal("")
|
|
}
|
|
provider.forUrl(
|
|
(if (isNullOrEmpty(principal)) httpUrl else httpUrl!!.resolve(principal!!)).toString(),
|
|
username,
|
|
password)
|
|
.findHomeset()
|
|
}
|
|
|
|
suspend fun calendars(interceptor: (okhttp3.Response) -> okhttp3.Response = { it }): List<Response> =
|
|
DavResource(
|
|
httpClient
|
|
.newBuilder()
|
|
.addNetworkInterceptor { interceptor(it.proceed(it.request())) }
|
|
.build(),
|
|
httpUrl!!
|
|
)
|
|
.propfind(1, *calendarProperties)
|
|
.filter { (response, relation) ->
|
|
relation == HrefRelation.MEMBER &&
|
|
response[ResourceType::class.java]?.types?.contains(CALENDAR) == true &&
|
|
response[SupportedCalendarComponentSet::class.java]?.supportsTasks == true
|
|
}
|
|
.map { (response, _) -> response }
|
|
|
|
@Throws(IOException::class, HttpException::class)
|
|
suspend fun deleteCollection() = withContext(Dispatchers.IO) {
|
|
DavResource(httpClient, httpUrl!!).delete(null) {}
|
|
}
|
|
|
|
@Throws(IOException::class, XmlPullParserException::class, HttpException::class)
|
|
suspend fun makeCollection(displayName: String, color: Int, icon: String?): String = withContext(Dispatchers.IO) {
|
|
val davResource = DavResource(httpClient, httpUrl!!.resolve(UUIDHelper.newUUID() + "/")!!)
|
|
val mkcolString = getMkcolString(displayName, color)
|
|
davResource.mkCol(mkcolString) {}
|
|
if (icon?.isNotBlank() == true) {
|
|
davResource.proppatch(CalendarIcon.NAME, icon)
|
|
}
|
|
davResource.location.toString()
|
|
}
|
|
|
|
@Throws(IOException::class, XmlPullParserException::class, HttpException::class)
|
|
suspend fun updateCollection(displayName: String, color: Int, icon: String?): String =
|
|
withContext(Dispatchers.IO) {
|
|
with(DavResource(httpClient, httpUrl!!)) {
|
|
proppatch(DisplayName.NAME, displayName)
|
|
if (color != 0) {
|
|
proppatch(
|
|
CalendarColor.NAME,
|
|
String.format("#%06X%02X", color and 0xFFFFFF, color ushr 24)
|
|
)
|
|
}
|
|
if (icon?.isNotBlank() == true) {
|
|
proppatch(CalendarIcon.NAME, icon)
|
|
}
|
|
location.toString()
|
|
}
|
|
}
|
|
|
|
@Throws(IOException::class, XmlPullParserException::class, HttpException::class)
|
|
suspend fun updateIcon(url: HttpUrl, icon: String?, onFailure: () -> Unit) =
|
|
withContext(Dispatchers.IO) {
|
|
with(DavResource(httpClient, url)) {
|
|
if (icon?.isNotBlank() == true) {
|
|
proppatch(CalendarIcon.NAME, icon, onFailure)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Throws(IOException::class, XmlPullParserException::class)
|
|
private fun getMkcolString(displayName: String, color: Int): String {
|
|
val xmlPullParserFactory = XmlPullParserFactory.newInstance()
|
|
val xml = xmlPullParserFactory.newSerializer()
|
|
val stringWriter = StringWriter()
|
|
with(xml) {
|
|
setOutput(stringWriter)
|
|
startDocument("UTF-8", null)
|
|
setPrefix("", NS_WEBDAV)
|
|
setPrefix("CAL", NS_CALDAV)
|
|
startTag(NS_WEBDAV, "mkcol")
|
|
startTag(NS_WEBDAV, "set")
|
|
startTag(NS_WEBDAV, "prop")
|
|
startTag(NS_WEBDAV, "resourcetype")
|
|
startTag(NS_WEBDAV, "collection")
|
|
endTag(NS_WEBDAV, "collection")
|
|
startTag(NS_CALDAV, "calendar")
|
|
endTag(NS_CALDAV, "calendar")
|
|
endTag(NS_WEBDAV, "resourcetype")
|
|
setDisplayName(xml, displayName)
|
|
if (color != 0) {
|
|
setColor(xml, color)
|
|
}
|
|
startTag(NS_CALDAV, "supported-calendar-component-set")
|
|
startTag(NS_CALDAV, "comp")
|
|
attribute(null, "name", "VTODO")
|
|
endTag(NS_CALDAV, "comp")
|
|
endTag(NS_CALDAV, "supported-calendar-component-set")
|
|
endTag(NS_WEBDAV, "prop")
|
|
endTag(NS_WEBDAV, "set")
|
|
endTag(NS_WEBDAV, "mkcol")
|
|
endDocument()
|
|
flush()
|
|
}
|
|
return stringWriter.toString()
|
|
}
|
|
|
|
@Throws(IOException::class)
|
|
private fun setDisplayName(xml: XmlSerializer, name: String) {
|
|
xml.startTag(NS_WEBDAV, "displayname")
|
|
xml.text(name)
|
|
xml.endTag(NS_WEBDAV, "displayname")
|
|
}
|
|
|
|
@Throws(IOException::class)
|
|
private fun setColor(xml: XmlSerializer, color: Int) {
|
|
xml.startTag(NS_APPLE_ICAL, "calendar-color")
|
|
xml.text(String.format("#%06X%02X", color and 0xFFFFFF, color ushr 24))
|
|
xml.endTag(NS_APPLE_ICAL, "calendar-color")
|
|
}
|
|
|
|
suspend fun share(
|
|
account: CaldavAccount,
|
|
href: String,
|
|
) {
|
|
when (account.serverType) {
|
|
SERVER_TASKS, SERVER_SABREDAV -> shareSabredav(href)
|
|
SERVER_OWNCLOUD, SERVER_NEXTCLOUD -> shareOwncloud(href)
|
|
else -> throw IllegalArgumentException()
|
|
}
|
|
}
|
|
|
|
private suspend fun shareOwncloud(href: String) =
|
|
withContext(Dispatchers.IO) {
|
|
DavCollection(httpClient, httpUrl!!)
|
|
.post("""
|
|
<x4:share xmlns:x4="$NS_OWNCLOUD">
|
|
<x4:set>
|
|
<x0:href xmlns:x0="$NS_WEBDAV">$href</x0:href>
|
|
</x4:set>
|
|
</x4:share>
|
|
""".trimIndent().toRequestBody(MIME_XML)
|
|
) {}
|
|
}
|
|
|
|
private suspend fun shareSabredav(href: String) =
|
|
withContext(Dispatchers.IO) {
|
|
DavCollection(httpClient, httpUrl!!)
|
|
.post("""
|
|
<D:share-resource xmlns:D="$NS_WEBDAV">
|
|
<D:sharee>
|
|
<D:href>$href</D:href>
|
|
<D:share-access>
|
|
<D:read-write />
|
|
</D:share-access>
|
|
</D:sharee>
|
|
</D:share-resource>
|
|
""".trimIndent().toRequestBody(MEDIATYPE_SHARING)) {}
|
|
}
|
|
|
|
suspend fun removePrincipal(
|
|
account: CaldavAccount,
|
|
calendar: CaldavCalendar,
|
|
href: String,
|
|
) {
|
|
when (account.serverType) {
|
|
SERVER_TASKS, SERVER_SABREDAV -> removeSabrePrincipal(calendar, href)
|
|
SERVER_OWNCLOUD, SERVER_NEXTCLOUD -> removeOwncloudPrincipal(calendar, href)
|
|
else -> throw IllegalArgumentException()
|
|
}
|
|
}
|
|
|
|
private suspend fun removeOwncloudPrincipal(calendar: CaldavCalendar, href: String) =
|
|
withContext(Dispatchers.IO) {
|
|
DavCollection(httpClient, calendar.url!!.toHttpUrl())
|
|
.post(
|
|
"""
|
|
<x4:share xmlns:x4="$NS_OWNCLOUD">
|
|
<x4:remove>
|
|
<x0:href xmlns:x0="$NS_WEBDAV">$href</x0:href>
|
|
</x4:remove>
|
|
</x4:share>
|
|
""".trimIndent().toRequestBody(MIME_XML)
|
|
) {}
|
|
}
|
|
|
|
private suspend fun removeSabrePrincipal(calendar: CaldavCalendar, href: String) =
|
|
withContext(Dispatchers.IO) {
|
|
DavCollection(httpClient, calendar.url!!.toHttpUrl())
|
|
.post(
|
|
"""
|
|
<D:share-resource xmlns:D="$NS_WEBDAV">
|
|
<D:sharee>
|
|
<D:href>$href</D:href>
|
|
<D:share-access>
|
|
<D:no-access />
|
|
</D:share-access>
|
|
</D:sharee>
|
|
</D:share-resource>
|
|
""".trimIndent().toRequestBody(MEDIATYPE_SHARING)
|
|
) {}
|
|
}
|
|
|
|
companion object {
|
|
private val MEDIATYPE_SHARING = "application/davsharing+xml".toMediaType()
|
|
|
|
private val calendarProperties = arrayOf(
|
|
ResourceType.NAME,
|
|
DisplayName.NAME,
|
|
SupportedCalendarComponentSet.NAME,
|
|
GetCTag.NAME,
|
|
CalendarColor.NAME,
|
|
SyncToken.NAME,
|
|
ShareAccess.NAME,
|
|
Invite.NAME,
|
|
OCOwnerPrincipal.NAME,
|
|
OCInvite.NAME,
|
|
CurrentUserPrivilegeSet.NAME,
|
|
CurrentUserPrincipal.NAME,
|
|
CalendarIcon.NAME,
|
|
)
|
|
|
|
private suspend fun DavResource.propfind(
|
|
depth: Int,
|
|
vararg reqProp: Property.Name
|
|
): List<Pair<Response, HrefRelation>> =
|
|
withContext(Dispatchers.IO) {
|
|
suspendCoroutine { cont ->
|
|
val responses = ArrayList<Pair<Response, HrefRelation>>()
|
|
propfind(depth, *reqProp) { response, relation ->
|
|
responses.add(Pair(response, relation))
|
|
}
|
|
cont.resumeWith(Result.success(responses))
|
|
}
|
|
}
|
|
|
|
fun DavResource.proppatch(
|
|
property: Property.Name,
|
|
value: String,
|
|
onFailure: () -> Unit = {},
|
|
) {
|
|
proppatch(
|
|
setProperties = mapOf(property to value),
|
|
removeProperties = emptyList(),
|
|
callback = { response, _ ->
|
|
if (!response.isSuccess()) {
|
|
Timber.e("${response.status} when updating $property: ${response.error}")
|
|
onFailure()
|
|
}
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|