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.
tasks/app/src/main/java/org/tasks/caldav/CaldavSynchronizer.kt

479 lines
19 KiB
Kotlin

package org.tasks.caldav
import android.content.Context
import at.bitfire.dav4jvm.DavCalendar
import at.bitfire.dav4jvm.DavCalendar.Companion.MIME_ICALENDAR
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyRegistry
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.Response.HrefRelation
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.exception.ServiceUnavailableException
import at.bitfire.dav4jvm.exception.UnauthorizedException
import at.bitfire.dav4jvm.property.CalendarColor
import at.bitfire.dav4jvm.property.CalendarData
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.GetETag
import at.bitfire.dav4jvm.property.GetETag.Companion.fromResponse
import at.bitfire.dav4jvm.property.SyncToken
import at.bitfire.ical4android.ICalendar.Companion.prodId
import com.todoroo.astrid.dao.TaskDao
import com.todoroo.astrid.data.Task
import com.todoroo.astrid.helper.UUIDHelper
import com.todoroo.astrid.service.TaskDeleter
import dagger.hilt.android.qualifiers.ApplicationContext
import net.fortuna.ical4j.model.property.ProdId
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import org.tasks.BuildConfig
import org.tasks.LocalBroadcastManager
import org.tasks.R
import org.tasks.Strings.isNullOrEmpty
import org.tasks.analytics.Firebase
import org.tasks.billing.Inventory
import org.tasks.caldav.iCalendar.Companion.fromVtodo
import org.tasks.caldav.property.Invite
import org.tasks.caldav.property.OCAccess
import org.tasks.caldav.property.OCInvite
import org.tasks.caldav.property.OCOwnerPrincipal
import org.tasks.caldav.property.OCUser
import org.tasks.caldav.property.PropertyUtils.register
import org.tasks.caldav.property.ShareAccess
import org.tasks.caldav.property.ShareAccess.Companion.READ
import org.tasks.caldav.property.ShareAccess.Companion.READ_WRITE
import org.tasks.caldav.property.ShareAccess.Companion.SHARED_OWNER
import org.tasks.caldav.property.Sharee
import org.tasks.data.CaldavAccount
import org.tasks.data.CaldavAccount.Companion.ERROR_UNAUTHORIZED
import org.tasks.data.CaldavAccount.Companion.SERVER_OPEN_XCHANGE
import org.tasks.data.CaldavAccount.Companion.SERVER_OWNCLOUD
import org.tasks.data.CaldavAccount.Companion.SERVER_SABREDAV
import org.tasks.data.CaldavAccount.Companion.SERVER_TASKS
import org.tasks.data.CaldavAccount.Companion.SERVER_UNKNOWN
import org.tasks.data.CaldavCalendar
import org.tasks.data.CaldavCalendar.Companion.ACCESS_OWNER
import org.tasks.data.CaldavCalendar.Companion.ACCESS_READ_ONLY
import org.tasks.data.CaldavCalendar.Companion.ACCESS_READ_WRITE
import org.tasks.data.CaldavCalendar.Companion.ACCESS_UNKNOWN
import org.tasks.data.CaldavCalendar.Companion.INVITE_ACCEPTED
import org.tasks.data.CaldavCalendar.Companion.INVITE_DECLINED
import org.tasks.data.CaldavCalendar.Companion.INVITE_INVALID
import org.tasks.data.CaldavCalendar.Companion.INVITE_NO_RESPONSE
import org.tasks.data.CaldavCalendar.Companion.INVITE_UNKNOWN
import org.tasks.data.CaldavDao
import org.tasks.data.CaldavTask
import org.tasks.data.PrincipalAccess
import org.tasks.data.PrincipalDao
import timber.log.Timber
import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.security.KeyManagementException
import java.security.NoSuchAlgorithmException
import javax.inject.Inject
import javax.net.ssl.SSLException
class CaldavSynchronizer @Inject constructor(
@param:ApplicationContext private val context: Context,
private val caldavDao: CaldavDao,
private val taskDao: TaskDao,
private val localBroadcastManager: LocalBroadcastManager,
private val taskDeleter: TaskDeleter,
private val inventory: Inventory,
private val firebase: Firebase,
private val provider: CaldavClientProvider,
private val iCal: iCalendar,
private val principalDao: PrincipalDao,
private val vtodoCache: VtodoCache,
) {
suspend fun sync(account: CaldavAccount) {
Thread.currentThread().contextClassLoader = context.classLoader
if (!inventory.hasPro && !account.isTasksOrg) {
setError(account, context.getString(R.string.requires_pro_subscription))
return
}
if (isNullOrEmpty(account.password)) {
setError(account, if (account.isTasksOrg) {
ERROR_UNAUTHORIZED
} else {
context.getString(R.string.password_required)
})
return
}
try {
synchronize(account)
} catch (e: SocketTimeoutException) {
setError(account, e.message)
} catch (e: SSLException) {
setError(account, e.message)
} catch (e: ConnectException) {
setError(account, e.message)
} catch (e: UnknownHostException) {
setError(account, e.message)
} catch (e: UnauthorizedException) {
setError(account, e.message)
} catch (e: ServiceUnavailableException) {
setError(account, e.message)
} catch (e: KeyManagementException) {
setError(account, e.message)
} catch (e: NoSuchAlgorithmException) {
setError(account, e.message)
} catch (e: IOException) {
setError(account, e.message)
} catch (e: HttpException) {
val message = when(e.code) {
402, in 500..599 -> e.message
else -> {
firebase.reportException(e)
e.message
}
}
setError(account, message)
} catch (e: Exception) {
setError(account, e.message)
firebase.reportException(e)
}
}
private suspend fun synchronize(account: CaldavAccount) {
val caldavClient = provider.forAccount(account)
var serverType = SERVER_UNKNOWN
val resources = caldavClient.calendars { chain ->
val response = chain.proceed(chain.request())
serverType = getServerType(account, response.headers)
response
}
if (serverType != account.serverType) {
account.serverType = serverType
caldavDao.update(account)
}
val urls = resources.map { it.href.toString() }.toHashSet()
Timber.d("Found calendars: %s", urls)
for (calendar in caldavDao.findDeletedCalendars(account.uuid!!, ArrayList(urls))) {
taskDeleter.delete(calendar)
}
for (resource in resources) {
val url = resource.href.toString()
var calendar = caldavDao.getCalendarByUrl(account.uuid!!, url)
val remoteName = resource[DisplayName::class.java]!!.displayName
val calendarColor = resource[CalendarColor::class.java]
val access = resource.accessLevel
if (access == ACCESS_UNKNOWN) {
firebase.logEvent(
R.string.event_sync_unknown_access,
R.string.param_type to
(resource[ShareAccess::class.java]?.access?.toString() ?: "???")
)
}
val color = calendarColor?.color ?: 0
if (calendar == null) {
calendar = CaldavCalendar()
calendar.name = remoteName
calendar.account = account.uuid
calendar.url = url
calendar.uuid = UUIDHelper.newUUID()
calendar.color = color
calendar.access = access
caldavDao.insert(calendar)
} else if (calendar.name != remoteName
|| calendar.color != color
|| calendar.access != access
) {
calendar.color = color
calendar.name = remoteName
calendar.access = access
caldavDao.update(calendar)
localBroadcastManager.broadcastRefreshList()
}
resource
.principals(account, calendar)
.let { principalDao.deleteRemoved(calendar.id, it.map(PrincipalAccess::id)) }
fetchChanges(calendar, resource, caldavClient.httpClient)
if (calendar.access != ACCESS_READ_ONLY) {
pushLocalChanges(calendar, caldavClient.httpClient, resource.href)
}
}
setError(account, "")
}
private fun getServerType(account: CaldavAccount, headers: Headers) = when {
account.isTasksOrg -> SERVER_TASKS
headers["DAV"]?.contains("oc-resource-sharing") == true -> SERVER_OWNCLOUD
headers["x-sabre-version"]?.isNotBlank() == true -> SERVER_SABREDAV
headers["server"] == "Openexchange WebDAV" -> SERVER_OPEN_XCHANGE
else -> SERVER_UNKNOWN
}
private suspend fun setError(account: CaldavAccount, message: String?) {
account.error = message
caldavDao.update(account)
localBroadcastManager.broadcastRefreshList()
if (!isNullOrEmpty(message)) {
Timber.e(message)
}
}
private suspend fun fetchChanges(
caldavCalendar: CaldavCalendar,
resource: Response,
httpClient: OkHttpClient) {
val httpUrl = resource.href
val remoteCtag = resource.ctag
if (caldavCalendar.ctag?.equals(remoteCtag) == true) {
Timber.d("%s up to date", caldavCalendar.name)
return
}
Timber.d("updating $caldavCalendar")
val davCalendar = DavCalendar(httpClient, httpUrl)
val members = ArrayList<Response>()
davCalendar.calendarQuery("VTODO", null, null) { response, relation ->
if (relation == HrefRelation.MEMBER) {
members.add(response)
}
}
val changed = members.filter { vCard: Response ->
val eTag = vCard[GetETag::class.java]?.eTag
if (eTag.isNullOrBlank()) {
return@filter false
}
eTag != caldavDao.getTask(caldavCalendar.uuid!!, vCard.hrefName())?.etag
}
for (items in changed.chunked(30)) {
val urls = items.map { it.href }
val responses = ArrayList<Response>()
davCalendar.multiget(urls) { response, relation ->
if (relation == HrefRelation.MEMBER) {
responses.add(response)
}
}
Timber.d("MULTI %s", urls)
for (vCard in responses) {
val eTag = vCard[GetETag::class.java]?.eTag
val url = vCard.href
if (eTag.isNullOrBlank()) {
throw DavException("Received CalDAV GET response without ETag for $url")
}
val vtodo = vCard[CalendarData::class.java]?.iCalendar
if (vtodo.isNullOrBlank()) {
throw DavException("Received CalDAV GET response without CalendarData for $url")
}
val fileName = vCard.hrefName()
val remote = fromVtodo(vtodo)
if (remote == null) {
Timber.e("Invalid VCALENDAR: %s", fileName)
return
}
val caldavTask = caldavDao.getTask(caldavCalendar.uuid!!, fileName)
iCal.fromVtodo(caldavCalendar, caldavTask, remote, vtodo, fileName, eTag)
}
}
caldavDao
.getRemoteObjects(caldavCalendar.uuid!!)
.subtract(members.map { it.hrefName() })
.takeIf { it.isNotEmpty() }
?.let {
Timber.d("DELETED $it")
taskDeleter.delete(caldavDao.getTasks(caldavCalendar.uuid!!, it.toList()))
}
caldavCalendar.ctag = remoteCtag
Timber.d("UPDATE %s", caldavCalendar)
caldavDao.update(caldavCalendar)
caldavDao.updateParents(caldavCalendar.uuid!!)
localBroadcastManager.broadcastRefresh()
}
private suspend fun pushLocalChanges(
caldavCalendar: CaldavCalendar, httpClient: OkHttpClient, httpUrl: HttpUrl) {
for (task in caldavDao.getMoved(caldavCalendar.uuid!!)) {
deleteRemoteResource(httpClient, httpUrl, caldavCalendar, task)
}
for (task in taskDao.getCaldavTasksToPush(caldavCalendar.uuid!!)) {
try {
pushTask(caldavCalendar, task, httpClient, httpUrl)
} catch (e: IOException) {
Timber.e(e)
}
}
}
private suspend fun deleteRemoteResource(
httpClient: OkHttpClient,
httpUrl: HttpUrl,
calendar: CaldavCalendar,
caldavTask: CaldavTask
): Boolean {
try {
if (!isNullOrEmpty(caldavTask.`object`)) {
val remote = DavResource(
httpClient, httpUrl.newBuilder().addPathSegment(caldavTask.`object`!!).build())
remote.delete(null) {}
}
} catch (e: HttpException) {
if (e.code != 404) {
Timber.e(e)
return false
}
} catch (e: IOException) {
Timber.e(e)
return false
}
vtodoCache.delete(calendar, caldavTask)
caldavDao.delete(caldavTask)
return true
}
private suspend fun pushTask(
calendar: CaldavCalendar,
task: Task,
httpClient: OkHttpClient,
httpUrl: HttpUrl
) {
Timber.d("pushing %s", task)
val caldavTask = caldavDao.getTask(task.id) ?: return
if (task.isDeleted) {
if (deleteRemoteResource(httpClient, httpUrl, calendar, caldavTask)) {
taskDeleter.delete(task)
}
return
}
val data = iCal.toVtodo(calendar, caldavTask, task)
val requestBody = RequestBody.create(MIME_ICALENDAR, data)
try {
val remote = DavResource(
httpClient, httpUrl.newBuilder().addPathSegment(caldavTask.`object`!!).build())
remote.put(requestBody) {
val getETag = fromResponse(it)
if (getETag != null && !isNullOrEmpty(getETag.eTag)) {
caldavTask.etag = getETag.eTag
vtodoCache.putVtodo(calendar, caldavTask, String(data))
}
}
} catch (e: HttpException) {
Timber.e(e)
return
}
caldavTask.lastSync = task.modificationDate
caldavDao.update(caldavTask)
Timber.d("SENT %s", caldavTask)
}
fun Response.principals(
account: CaldavAccount,
list: CaldavCalendar
): List<PrincipalAccess> {
val access = ArrayList<PrincipalAccess>()
this[Invite::class.java]
?.sharees
?.filter { it.href?.let { href -> !isCurrentUser(href) } ?: false }
?.map {
val principal = principalDao.getOrCreatePrincipal(
account,
it.href!!,
it.properties
.find { p -> p is DisplayName }
?.let { name -> (name as DisplayName).displayName }
)
principalDao.getOrCreateAccess(
list,
principal,
invite = it.response?.toStatus ?: INVITE_UNKNOWN,
access = it.access?.access?.toAccess ?: ACCESS_UNKNOWN
)
}
?.let { access.addAll(it) }
this[OCInvite::class.java]?.users
?.map {
val principal = principalDao.getOrCreatePrincipal(account, it.href)
principalDao.getOrCreateAccess(
list,
principal,
it.response.toStatus,
it.access.access.toAccess
)
}
?.let {
if (!isOwncloudOwner) {
this@principals[OCOwnerPrincipal::class.java]?.owner?.let { href ->
val principal = principalDao.getOrCreatePrincipal(account, href)
access.add(principalDao.getOrCreateAccess(
list,
principal,
INVITE_ACCEPTED,
ACCESS_OWNER
))
}
}
access.addAll(it)
}
return access
}
companion object {
init {
prodId = ProdId("+//IDN tasks.org//android-" + BuildConfig.VERSION_CODE + "//EN")
}
fun registerFactories() {
PropertyRegistry.register(
ShareAccess.Factory(),
Invite.Factory(),
OCOwnerPrincipal.Factory(),
OCInvite.Factory(),
)
}
val Response.ctag: String?
get() = this[SyncToken::class.java]?.token ?: this[GetCTag::class.java]?.cTag
val Response.accessLevel: Int
get() {
this[ShareAccess::class.java]?.let {
return when (it.access) {
SHARED_OWNER -> ACCESS_OWNER
READ_WRITE -> ACCESS_READ_WRITE
READ -> ACCESS_READ_ONLY
else -> ACCESS_UNKNOWN
}
}
if (isOwncloudOwner) {
return ACCESS_OWNER
}
return when (this[CurrentUserPrivilegeSet::class.java]?.mayWriteContent) {
false -> ACCESS_READ_ONLY
else -> ACCESS_READ_WRITE
}
}
private val Response.isOwncloudOwner: Boolean
get() = this[OCOwnerPrincipal::class.java]?.owner?.let { isCurrentUser(it) } ?: false
private fun Response.isCurrentUser(href: String) =
this[CurrentUserPrincipal::class.java]?.href?.endsWith("$href/") == true
private val Property.Name.toAccess: Int
get() = when (this) {
SHARED_OWNER, OCAccess.SHARED_OWNER -> ACCESS_OWNER
READ_WRITE, OCAccess.READ_WRITE -> ACCESS_READ_WRITE
READ, OCAccess.READ -> ACCESS_READ_ONLY
else -> ACCESS_UNKNOWN
}
private val Property.Name.toStatus: Int
get() = when (this) {
Sharee.INVITE_ACCEPTED, OCUser.INVITE_ACCEPTED -> INVITE_ACCEPTED
Sharee.INVITE_NORESPONSE, OCUser.INVITE_NORESPONSE -> INVITE_NO_RESPONSE
Sharee.INVITE_DECLINED, OCUser.INVITE_DECLINED -> INVITE_DECLINED
Sharee.INVITE_INVALID, OCUser.INVITE_INVALID -> INVITE_INVALID
else -> INVITE_UNKNOWN
}
}
}