diff --git a/app/src/main/java/org/tasks/caldav/CaldavSynchronizer.java b/app/src/main/java/org/tasks/caldav/CaldavSynchronizer.java deleted file mode 100644 index 1ecf566b0..000000000 --- a/app/src/main/java/org/tasks/caldav/CaldavSynchronizer.java +++ /dev/null @@ -1,336 +0,0 @@ -package org.tasks.caldav; - -import static com.google.common.collect.Iterables.filter; -import static com.google.common.collect.Iterables.partition; -import static com.google.common.collect.Lists.newArrayList; -import static com.google.common.collect.Lists.transform; -import static com.google.common.collect.Sets.difference; -import static com.google.common.collect.Sets.newHashSet; -import static org.tasks.Strings.isNullOrEmpty; -import static org.tasks.time.DateTimeUtils.currentTimeMillis; - -import android.content.Context; -import androidx.annotation.Nullable; -import at.bitfire.dav4jvm.DavCalendar; -import at.bitfire.dav4jvm.DavResource; -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.DisplayName; -import at.bitfire.dav4jvm.property.GetCTag; -import at.bitfire.dav4jvm.property.GetETag; -import at.bitfire.dav4jvm.property.SyncToken; -import at.bitfire.ical4android.ICalendar; -import com.google.common.collect.Iterables; -import com.todoroo.astrid.dao.TaskDaoBlocking; -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 java.io.IOException; -import java.net.ConnectException; -import java.net.SocketTimeoutException; -import java.net.UnknownHostException; -import java.security.KeyManagementException; -import java.security.NoSuchAlgorithmException; -import java.util.ArrayList; -import java.util.List; -import java.util.Set; -import javax.inject.Inject; -import javax.net.ssl.SSLException; -import net.fortuna.ical4j.model.property.ProdId; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.RequestBody; -import org.tasks.BuildConfig; -import org.tasks.LocalBroadcastManager; -import org.tasks.R; -import org.tasks.analytics.Firebase; -import org.tasks.billing.Inventory; -import org.tasks.data.CaldavAccount; -import org.tasks.data.CaldavCalendar; -import org.tasks.data.CaldavDaoBlocking; -import org.tasks.data.CaldavTask; -import timber.log.Timber; - -public class CaldavSynchronizer { - - static { - ICalendar.Companion.setProdId( - new ProdId("+//IDN tasks.org//android-" + BuildConfig.VERSION_CODE + "//EN")); - } - - private final CaldavDaoBlocking caldavDao; - private final TaskDaoBlocking taskDao; - private final LocalBroadcastManager localBroadcastManager; - private final TaskDeleter taskDeleter; - private final Inventory inventory; - private final Firebase firebase; - private final CaldavClient client; - private final iCalendar iCal; - private final Context context; - - @Inject - public CaldavSynchronizer( - @ApplicationContext Context context, - CaldavDaoBlocking caldavDao, - TaskDaoBlocking taskDao, - LocalBroadcastManager localBroadcastManager, - TaskDeleter taskDeleter, - Inventory inventory, - Firebase firebase, - CaldavClient client, - iCalendar iCal) { - this.context = context; - this.caldavDao = caldavDao; - this.taskDao = taskDao; - this.localBroadcastManager = localBroadcastManager; - this.taskDeleter = taskDeleter; - this.inventory = inventory; - this.firebase = firebase; - this.client = client; - this.iCal = iCal; - } - - public void sync(CaldavAccount account) { - if (!inventory.hasPro()) { - setError(account, context.getString(R.string.requires_pro_subscription)); - return; - } - if (isNullOrEmpty(account.getPassword())) { - setError(account, context.getString(R.string.password_required)); - return; - } - try { - synchronize(account); - } catch (SocketTimeoutException - | SSLException - | ConnectException - | UnknownHostException - | UnauthorizedException - | ServiceUnavailableException - | KeyManagementException - | NoSuchAlgorithmException e) { - setError(account, e.getMessage()); - } catch (IOException | DavException e) { - setError(account, e.getMessage()); - if (!(e instanceof HttpException) || ((HttpException) e).getCode() < 500) { - firebase.reportException(e); - } - } - } - - private void synchronize(CaldavAccount account) - throws IOException, DavException, KeyManagementException, NoSuchAlgorithmException { - CaldavClient caldavClient = client.forAccount(account); - List resources = caldavClient.getCalendars(); - Set urls = newHashSet(transform(resources, c -> c.getHref().toString())); - Timber.d("Found calendars: %s", urls); - for (CaldavCalendar calendar : - caldavDao.findDeletedCalendars(account.getUuid(), new ArrayList<>(urls))) { - taskDeleter.delete(calendar); - } - for (Response resource : resources) { - String url = resource.getHref().toString(); - - CaldavCalendar calendar = caldavDao.getCalendarByUrl(account.getUuid(), url); - String remoteName = resource.get(DisplayName.class).getDisplayName(); - CalendarColor calendarColor = resource.get(CalendarColor.class); - int color = calendarColor == null ? 0 : calendarColor.getColor(); - if (calendar == null) { - calendar = new CaldavCalendar(); - calendar.setName(remoteName); - calendar.setAccount(account.getUuid()); - calendar.setUrl(url); - calendar.setUuid(UUIDHelper.newUUID()); - calendar.setColor(color); - caldavDao.insert(calendar); - } else if (!calendar.getName().equals(remoteName) || calendar.getColor() != color) { - calendar.setColor(color); - calendar.setName(remoteName); - caldavDao.update(calendar); - localBroadcastManager.broadcastRefreshList(); - } - sync(calendar, resource, caldavClient.getHttpClient()); - } - setError(account, ""); - } - - private void setError(CaldavAccount account, String message) { - account.setError(message); - caldavDao.update(account); - localBroadcastManager.broadcastRefreshList(); - if (!isNullOrEmpty(message)) { - Timber.e(message); - } - } - - private void sync(CaldavCalendar caldavCalendar, Response resource, OkHttpClient httpClient) - throws DavException { - Timber.d("sync(%s)", caldavCalendar); - HttpUrl httpUrl = resource.getHref(); - pushLocalChanges(caldavCalendar, httpClient, httpUrl); - - SyncToken syncToken = resource.get(SyncToken.class); - GetCTag ctag = resource.get(GetCTag.class); - @Nullable String remoteCtag = null; - if (syncToken != null) { - remoteCtag = syncToken.getToken(); - } else if (ctag != null) { - remoteCtag = ctag.getCTag(); - } - String localCtag = caldavCalendar.getCtag(); - - if (localCtag != null && localCtag.equals(remoteCtag)) { - Timber.d("%s up to date", caldavCalendar.getName()); - return; - } - - DavCalendar davCalendar = new DavCalendar(httpClient, httpUrl); - - ResponseList members = new ResponseList(HrefRelation.MEMBER); - davCalendar.calendarQuery("VTODO", null, null, members); - - Iterable changed = - filter( - members, - vCard -> { - GetETag eTag = vCard.get(GetETag.class); - if (eTag == null || isNullOrEmpty(eTag.getETag())) { - return false; - } - CaldavTask caldavTask = caldavDao.getTask(caldavCalendar.getUuid(), vCard.hrefName()); - return caldavTask == null || !eTag.getETag().equals(caldavTask.getEtag()); - }); - - for (List items : partition(changed, 30)) { - ArrayList urls = newArrayList(Iterables.transform(items, Response::getHref)); - ResponseList responses = new ResponseList(HrefRelation.MEMBER); - davCalendar.multiget(urls, responses); - - Timber.d("MULTI %s", urls); - - for (Response vCard : responses) { - GetETag eTag = vCard.get(GetETag.class); - HttpUrl url = vCard.getHref(); - if (eTag == null || isNullOrEmpty(eTag.getETag())) { - throw new DavException("Received CalDAV GET response without ETag for " + url); - } - CalendarData calendarData = vCard.get(CalendarData.class); - if (calendarData == null || isNullOrEmpty(calendarData.getICalendar())) { - throw new DavException("Received CalDAV GET response without CalendarData for " + url); - } - String fileName = vCard.hrefName(); - String vtodo = calendarData.getICalendar(); - at.bitfire.ical4android.Task remote = iCalendar.Companion.fromVtodo(vtodo); - if (remote == null) { - Timber.e("Invalid VCALENDAR: %s", fileName); - return; - } - - CaldavTask caldavTask = caldavDao.getTask(caldavCalendar.getUuid(), fileName); - iCal.fromVtodo(caldavCalendar, caldavTask, remote, vtodo, fileName, eTag.getETag()); - } - } - - List deleted = - new ArrayList<>( - difference( - newHashSet(caldavDao.getObjects(caldavCalendar.getUuid())), - newHashSet(transform(members, Response::hrefName)))); - if (deleted.size() > 0) { - Timber.d("DELETED %s", deleted); - taskDeleter.delete(caldavDao.getTasks(caldavCalendar.getUuid(), deleted)); - } - - caldavCalendar.setCtag(remoteCtag); - Timber.d("UPDATE %s", caldavCalendar); - caldavDao.update(caldavCalendar); - - caldavDao.updateParents(caldavCalendar.getUuid()); - - localBroadcastManager.broadcastRefresh(); - } - - private void pushLocalChanges( - CaldavCalendar caldavCalendar, OkHttpClient httpClient, HttpUrl httpUrl) { - - for (CaldavTask task : caldavDao.getDeleted(caldavCalendar.getUuid())) { - deleteRemoteResource(httpClient, httpUrl, task); - } - - for (Task task : taskDao.getCaldavTasksToPush(caldavCalendar.getUuid())) { - try { - pushTask(task, httpClient, httpUrl); - } catch (IOException e) { - Timber.e(e); - } - } - } - - private boolean deleteRemoteResource( - OkHttpClient httpClient, HttpUrl httpUrl, CaldavTask caldavTask) { - try { - if (!isNullOrEmpty(caldavTask.getObject())) { - DavResource remote = - new DavResource( - httpClient, httpUrl.newBuilder().addPathSegment(caldavTask.getObject()).build()); - remote.delete(null, response -> null); - } - } catch (HttpException e) { - if (e.getCode() != 404) { - Timber.e(e); - return false; - } - } catch (IOException e) { - Timber.e(e); - return false; - } - caldavDao.delete(caldavTask); - return true; - } - - private void pushTask(Task task, OkHttpClient httpClient, HttpUrl httpUrl) throws IOException { - Timber.d("pushing %s", task); - CaldavTask caldavTask = caldavDao.getTask(task.getId()); - - if (caldavTask == null) { - return; - } - - if (task.isDeleted()) { - if (deleteRemoteResource(httpClient, httpUrl, caldavTask)) { - taskDeleter.delete(task); - } - return; - } - - byte[] data = iCal.toVtodo(caldavTask, task); - RequestBody requestBody = RequestBody.create(DavCalendar.Companion.getMIME_ICALENDAR(), data); - - try { - DavResource remote = - new DavResource( - httpClient, httpUrl.newBuilder().addPathSegment(caldavTask.getObject()).build()); - org.tasks.caldav.Response response = new org.tasks.caldav.Response(); - remote.put(requestBody, null, false, response); - GetETag getETag = GetETag.Companion.fromResponse(response.get()); - if (getETag != null && !isNullOrEmpty(getETag.getETag())) { - caldavTask.setEtag(getETag.getETag()); - caldavTask.setVtodo(new String(data)); - } - } catch (HttpException e) { - Timber.e(e); - return; - } - - caldavTask.setLastSync(currentTimeMillis()); - caldavDao.update(caldavTask); - Timber.d("SENT %s", caldavTask); - } -} diff --git a/app/src/main/java/org/tasks/caldav/CaldavSynchronizer.kt b/app/src/main/java/org/tasks/caldav/CaldavSynchronizer.kt new file mode 100644 index 000000000..585a0e3a8 --- /dev/null +++ b/app/src/main/java/org/tasks/caldav/CaldavSynchronizer.kt @@ -0,0 +1,278 @@ +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.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.* +import at.bitfire.dav4jvm.property.GetETag.Companion.fromResponse +import at.bitfire.ical4android.ICalendar.Companion.prodId +import com.todoroo.astrid.dao.TaskDaoBlocking +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.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.data.CaldavAccount +import org.tasks.data.CaldavCalendar +import org.tasks.data.CaldavDaoBlocking +import org.tasks.data.CaldavTask +import org.tasks.time.DateTimeUtils +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 java.util.* +import javax.inject.Inject +import javax.net.ssl.SSLException + +class CaldavSynchronizer @Inject constructor( + @param:ApplicationContext private val context: Context, + private val caldavDao: CaldavDaoBlocking, + private val taskDao: TaskDaoBlocking, + private val localBroadcastManager: LocalBroadcastManager, + private val taskDeleter: TaskDeleter, + private val inventory: Inventory, + private val firebase: Firebase, + private val client: CaldavClient, + private val iCal: iCalendar) { + companion object { + init { + prodId = ProdId("+//IDN tasks.org//android-" + BuildConfig.VERSION_CODE + "//EN") + } + } + + fun sync(account: CaldavAccount) { + if (!inventory.hasPro()) { + setError(account, context.getString(R.string.requires_pro_subscription)) + return + } + if (isNullOrEmpty(account.password)) { + setError(account, 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: DavException) { + setError(account, e.message) + if (e !is HttpException || e.code < 500) { + firebase.reportException(e) + } + } + } + + @Throws(IOException::class, DavException::class, KeyManagementException::class, NoSuchAlgorithmException::class) + private fun synchronize(account: CaldavAccount) { + val caldavClient = client.forAccount(account) + val resources = caldavClient.calendars + 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 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 + caldavDao.insert(calendar) + } else if (calendar.name != remoteName || calendar.color != color) { + calendar.color = color + calendar.name = remoteName + caldavDao.update(calendar) + localBroadcastManager.broadcastRefreshList() + } + sync(calendar, resource, caldavClient.httpClient) + } + setError(account, "") + } + + private fun setError(account: CaldavAccount, message: String?) { + account.error = message + caldavDao.update(account) + localBroadcastManager.broadcastRefreshList() + if (!isNullOrEmpty(message)) { + Timber.e(message) + } + } + + @Throws(DavException::class) + private fun sync(caldavCalendar: CaldavCalendar, resource: at.bitfire.dav4jvm.Response, httpClient: OkHttpClient) { + Timber.d("sync(%s)", caldavCalendar) + val httpUrl = resource.href + pushLocalChanges(caldavCalendar, httpClient, httpUrl) + val syncToken = resource[SyncToken::class.java] + val ctag = resource[GetCTag::class.java] + var remoteCtag: String? = null + if (syncToken != null) { + remoteCtag = syncToken.token + } else if (ctag != null) { + remoteCtag = ctag.cTag + } + val localCtag = caldavCalendar.ctag + if (localCtag != null && localCtag == remoteCtag) { + Timber.d("%s up to date", caldavCalendar.name) + return + } + val davCalendar = DavCalendar(httpClient, httpUrl) + val members = ResponseList(HrefRelation.MEMBER) + davCalendar.calendarQuery("VTODO", null, null, members) + val changed = members.filter { vCard: at.bitfire.dav4jvm.Response -> + val eTag = vCard[GetETag::class.java] + if (eTag == null || isNullOrEmpty(eTag.eTag)) { + return@filter false + } + val caldavTask = caldavDao.getTask(caldavCalendar.uuid!!, vCard.hrefName()) + caldavTask == null || eTag.eTag != caldavTask.etag + } + for (items in changed.chunked(30)) { + val urls = items.map { it.href } + val responses = ResponseList(HrefRelation.MEMBER) + davCalendar.multiget(urls, responses) + Timber.d("MULTI %s", urls) + for (vCard in responses) { + val eTag = vCard[GetETag::class.java] + val url = vCard.href + if (eTag == null || isNullOrEmpty(eTag.eTag)) { + throw DavException("Received CalDAV GET response without ETag for $url") + } + val calendarData = vCard[CalendarData::class.java] + if (calendarData == null || isNullOrEmpty(calendarData.iCalendar)) { + throw DavException("Received CalDAV GET response without CalendarData for $url") + } + val fileName = vCard.hrefName() + val vtodo = calendarData.iCalendar + 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.eTag) + } + } + val deleted = caldavDao + .getObjects(caldavCalendar.uuid!!) + .subtract(members.map { it.hrefName() }) + .toList() + if (deleted.isNotEmpty()) { + Timber.d("DELETED %s", deleted) + taskDeleter.delete(caldavDao.getTasks(caldavCalendar.uuid!!, deleted)) + } + caldavCalendar.ctag = remoteCtag + Timber.d("UPDATE %s", caldavCalendar) + caldavDao.update(caldavCalendar) + caldavDao.updateParents(caldavCalendar.uuid!!) + localBroadcastManager.broadcastRefresh() + } + + private fun pushLocalChanges( + caldavCalendar: CaldavCalendar, httpClient: OkHttpClient, httpUrl: HttpUrl) { + for (task in caldavDao.getDeleted(caldavCalendar.uuid!!)) { + deleteRemoteResource(httpClient, httpUrl, task) + } + for (task in taskDao.getCaldavTasksToPush(caldavCalendar.uuid!!)) { + try { + pushTask(task, httpClient, httpUrl) + } catch (e: IOException) { + Timber.e(e) + } + } + } + + private fun deleteRemoteResource( + httpClient: OkHttpClient, httpUrl: HttpUrl, 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 + } + caldavDao.delete(caldavTask) + return true + } + + @Throws(IOException::class) + private fun pushTask(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, caldavTask)) { + taskDeleter.delete(task) + } + return + } + val data = iCal.toVtodo(caldavTask, task) + val requestBody = RequestBody.create(MIME_ICALENDAR, data) + try { + val remote = DavResource( + httpClient, httpUrl.newBuilder().addPathSegment(caldavTask.`object`!!).build()) + val response = Response() + remote.put(requestBody, null, false, response) + val getETag = fromResponse(response.get()) + if (getETag != null && !isNullOrEmpty(getETag.eTag)) { + caldavTask.etag = getETag.eTag + caldavTask.vtodo = String(data) + } + } catch (e: HttpException) { + Timber.e(e) + return + } + caldavTask.lastSync = DateTimeUtils.currentTimeMillis() + caldavDao.update(caldavTask) + Timber.d("SENT %s", caldavTask) + } +} \ No newline at end of file