diff --git a/app/src/androidTest/java/org/tasks/caldav/CaldavClientTest.kt b/app/src/androidTest/java/org/tasks/caldav/CaldavClientTest.kt index 8bb71e584..60a4982d8 100644 --- a/app/src/androidTest/java/org/tasks/caldav/CaldavClientTest.kt +++ b/app/src/androidTest/java/org/tasks/caldav/CaldavClientTest.kt @@ -3,16 +3,21 @@ package org.tasks.caldav import android.content.Context import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.runBlocking import org.junit.Test import org.junit.runner.RunWith +import org.tasks.DebugNetworkInterceptor import org.tasks.TestUtilities.newPreferences +import org.tasks.security.KeyStoreEncryption @RunWith(AndroidJUnit4::class) class CaldavClientTest { @Test fun dontCrashOnSpaceInUrl() { - val context = ApplicationProvider.getApplicationContext() - CaldavClient(context, null, newPreferences(context), null) - .forUrl("https://example.com/remote.php/a space/", "username", "password") + runBlocking { + val context = ApplicationProvider.getApplicationContext() + CaldavClient(context, KeyStoreEncryption(), newPreferences(context), DebugNetworkInterceptor(ApplicationProvider.getApplicationContext())) + .forUrl("https://example.com/remote.php/a space/", "username", "password") + } } } \ No newline at end of file diff --git a/app/src/main/java/org/tasks/caldav/AddCaldavAccountViewModel.kt b/app/src/main/java/org/tasks/caldav/AddCaldavAccountViewModel.kt index 8bb780ac6..5540dcf65 100644 --- a/app/src/main/java/org/tasks/caldav/AddCaldavAccountViewModel.kt +++ b/app/src/main/java/org/tasks/caldav/AddCaldavAccountViewModel.kt @@ -6,6 +6,6 @@ import org.tasks.ui.CompletableViewModel class AddCaldavAccountViewModel @ViewModelInject constructor( private val client: CaldavClient) : CompletableViewModel() { suspend fun addAccount(url: String, username: String, password: String) { - run { client.setForeground().forUrl(url, username, password).homeSet } + run { client.setForeground().forUrl(url, username, password).homeSet() } } } \ No newline at end of file diff --git a/app/src/main/java/org/tasks/caldav/BaseCaldavAccountSettingsActivity.kt b/app/src/main/java/org/tasks/caldav/BaseCaldavAccountSettingsActivity.kt index e68d50370..dcb1bc6c7 100644 --- a/app/src/main/java/org/tasks/caldav/BaseCaldavAccountSettingsActivity.kt +++ b/app/src/main/java/org/tasks/caldav/BaseCaldavAccountSettingsActivity.kt @@ -226,7 +226,7 @@ abstract class BaseCaldavAccountSettingsActivity : ThemedInjectingAppCompatActiv } needsValidation() -> { showProgressIndicator() - updateAccount(url, username, password) + updateAccount(url, username, password!!) } hasChanges() -> { updateAccount() @@ -238,7 +238,7 @@ abstract class BaseCaldavAccountSettingsActivity : ThemedInjectingAppCompatActiv } protected abstract suspend fun addAccount(url: String, username: String, password: String) - protected abstract suspend fun updateAccount(url: String, username: String, password: String?) + protected abstract suspend fun updateAccount(url: String, username: String, password: String) protected abstract suspend fun updateAccount() protected abstract val helpUrl: String? diff --git a/app/src/main/java/org/tasks/caldav/CaldavAccountSettingsActivity.kt b/app/src/main/java/org/tasks/caldav/CaldavAccountSettingsActivity.kt index 321f81896..5b5d776fa 100644 --- a/app/src/main/java/org/tasks/caldav/CaldavAccountSettingsActivity.kt +++ b/app/src/main/java/org/tasks/caldav/CaldavAccountSettingsActivity.kt @@ -56,7 +56,7 @@ class CaldavAccountSettingsActivity : BaseCaldavAccountSettingsActivity(), Toolb override suspend fun addAccount(url: String, username: String, password: String) = addCaldavAccountViewModel.addAccount(url, username, password) - override suspend fun updateAccount(url: String, username: String, password: String?) = + override suspend fun updateAccount(url: String, username: String, password: String) = updateCaldavAccountViewModel.updateCaldavAccount(url, username, password) override suspend fun updateAccount() = diff --git a/app/src/main/java/org/tasks/caldav/CaldavClient.java b/app/src/main/java/org/tasks/caldav/CaldavClient.java deleted file mode 100644 index f8ce44a26..000000000 --- a/app/src/main/java/org/tasks/caldav/CaldavClient.java +++ /dev/null @@ -1,344 +0,0 @@ -package org.tasks.caldav; - -import static at.bitfire.dav4jvm.XmlUtils.NS_CALDAV; -import static at.bitfire.dav4jvm.XmlUtils.NS_CARDDAV; -import static at.bitfire.dav4jvm.XmlUtils.NS_WEBDAV; -import static org.tasks.Strings.isNullOrEmpty; - -import android.content.Context; -import at.bitfire.cert4android.CustomCertManager; -import at.bitfire.cert4android.CustomCertManager.CustomHostnameVerifier; -import at.bitfire.dav4jvm.BasicDigestAuthHandler; -import at.bitfire.dav4jvm.DavResource; -import at.bitfire.dav4jvm.Property.Name; -import at.bitfire.dav4jvm.Response; -import at.bitfire.dav4jvm.Response.HrefRelation; -import at.bitfire.dav4jvm.XmlUtils; -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.DisplayName; -import at.bitfire.dav4jvm.property.GetCTag; -import at.bitfire.dav4jvm.property.ResourceType; -import at.bitfire.dav4jvm.property.SupportedCalendarComponentSet; -import at.bitfire.dav4jvm.property.SyncToken; -import com.todoroo.astrid.helper.UUIDHelper; -import dagger.hilt.android.qualifiers.ApplicationContext; -import java.io.IOException; -import java.io.StringWriter; -import java.security.KeyManagementException; -import java.security.NoSuchAlgorithmException; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.TimeUnit; -import javax.inject.Inject; -import javax.net.ssl.SSLContext; -import javax.net.ssl.TrustManager; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.OkHttpClient.Builder; -import okhttp3.internal.tls.OkHostnameVerifier; -import org.tasks.DebugNetworkInterceptor; -import org.tasks.R; -import org.tasks.data.CaldavAccount; -import org.tasks.data.CaldavCalendar; -import org.tasks.preferences.Preferences; -import org.tasks.security.KeyStoreEncryption; -import org.tasks.ui.DisplayableException; -import org.xmlpull.v1.XmlPullParserException; -import org.xmlpull.v1.XmlPullParserFactory; -import org.xmlpull.v1.XmlSerializer; -import timber.log.Timber; - -public class CaldavClient { - - private final KeyStoreEncryption encryption; - private final Preferences preferences; - private final DebugNetworkInterceptor interceptor; - private final OkHttpClient httpClient; - private final HttpUrl httpUrl; - private final Context context; - private final BasicDigestAuthHandler basicDigestAuthHandler; - private boolean foreground; - - @Inject - CaldavClient( - @ApplicationContext Context context, - KeyStoreEncryption encryption, - Preferences preferences, - DebugNetworkInterceptor interceptor) { - this.context = context; - this.encryption = encryption; - this.preferences = preferences; - this.interceptor = interceptor; - httpClient = null; - httpUrl = null; - basicDigestAuthHandler = null; - } - - private CaldavClient( - Context context, - KeyStoreEncryption encryption, - Preferences preferences, - DebugNetworkInterceptor interceptor, - String url, - String username, - String password, - boolean foreground) - throws NoSuchAlgorithmException, KeyManagementException { - this.context = context; - this.encryption = encryption; - this.preferences = preferences; - this.interceptor = interceptor; - - CustomCertManager customCertManager = new CustomCertManager(context); - customCertManager.setAppInForeground(foreground); - CustomHostnameVerifier hostnameVerifier = - customCertManager.hostnameVerifier(OkHostnameVerifier.INSTANCE); - SSLContext sslContext = SSLContext.getInstance("TLS"); - sslContext.init(null, new TrustManager[] {customCertManager}, null); - - basicDigestAuthHandler = new BasicDigestAuthHandler(null, username, password); - Builder builder = - new OkHttpClient() - .newBuilder() - .addNetworkInterceptor(basicDigestAuthHandler) - .authenticator(basicDigestAuthHandler) - .cookieJar(new MemoryCookieStore()) - .followRedirects(false) - .followSslRedirects(true) - .sslSocketFactory(sslContext.getSocketFactory(), customCertManager) - .hostnameVerifier(hostnameVerifier) - .connectTimeout(15, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .readTimeout(120, TimeUnit.SECONDS); - if (preferences.isFlipperEnabled()) { - interceptor.add(builder); - } - httpClient = builder.build(); - httpUrl = HttpUrl.parse(url); - } - - public CaldavClient forAccount(CaldavAccount account) - throws NoSuchAlgorithmException, KeyManagementException { - return forUrl(account.getUrl(), account.getUsername(), account.getPassword(encryption)); - } - - CaldavClient forCalendar(CaldavAccount account, CaldavCalendar calendar) - throws NoSuchAlgorithmException, KeyManagementException { - return forUrl(calendar.getUrl(), account.getUsername(), account.getPassword(encryption)); - } - - CaldavClient forUrl(String url, String username, String password) - throws KeyManagementException, NoSuchAlgorithmException { - return new CaldavClient( - context, encryption, preferences, interceptor, url, username, password, foreground); - } - - private String tryFindPrincipal(String link) throws DavException, IOException { - HttpUrl url = httpUrl.resolve(link); - Timber.d("Checking for principal: %s", url); - DavResource davResource = new DavResource(httpClient, url); - ResponseList responses = new ResponseList(); - davResource.propfind(0, new Name[] {CurrentUserPrincipal.NAME}, responses); - if (!responses.isEmpty()) { - Response response = responses.get(0); - CurrentUserPrincipal currentUserPrincipal = response.get(CurrentUserPrincipal.class); - if (currentUserPrincipal != null) { - String href = currentUserPrincipal.getHref(); - if (!isNullOrEmpty(href)) { - return href; - } - } - } - return null; - } - - private String findHomeset() throws DavException, IOException { - DavResource davResource = new DavResource(httpClient, httpUrl); - ResponseList responses = new ResponseList(); - davResource.propfind(0, new Name[] {CalendarHomeSet.NAME}, responses); - Response response = responses.get(0); - CalendarHomeSet calendarHomeSet = response.get(CalendarHomeSet.class); - if (calendarHomeSet == null) { - throw new DisplayableException(R.string.caldav_home_set_not_found); - } - List hrefs = calendarHomeSet.getHrefs(); - if (hrefs.size() != 1) { - throw new DisplayableException(R.string.caldav_home_set_not_found); - } - String homeSet = hrefs.get(0); - if (isNullOrEmpty(homeSet)) { - throw new DisplayableException(R.string.caldav_home_set_not_found); - } - return davResource.getLocation().resolve(homeSet).toString(); - } - - String getHomeSet() - throws IOException, DavException, NoSuchAlgorithmException, KeyManagementException { - String principal = null; - try { - principal = tryFindPrincipal("/.well-known/caldav"); - } catch (Exception e) { - if (e instanceof HttpException && ((HttpException) e).getCode() == 401) { - throw e; - } - - Timber.w(e); - } - if (principal == null) { - principal = tryFindPrincipal(""); - } - return forUrl( - (isNullOrEmpty(principal) ? this.httpUrl : httpUrl.resolve(principal)).toString(), - basicDigestAuthHandler.getUsername(), - basicDigestAuthHandler.getPassword()) - .findHomeset(); - } - - public List getCalendars() throws IOException, DavException { - DavResource davResource = new DavResource(httpClient, httpUrl); - ResponseList responses = new ResponseList(HrefRelation.MEMBER); - davResource.propfind( - 1, - new Name[] { - ResourceType.NAME, - DisplayName.NAME, - SupportedCalendarComponentSet.NAME, - GetCTag.NAME, - CalendarColor.NAME, - SyncToken.NAME - }, - responses); - List urls = new ArrayList<>(); - for (Response member : responses) { - ResourceType resourceType = member.get(ResourceType.class); - if (resourceType == null - || !resourceType.getTypes().contains(ResourceType.Companion.getCALENDAR())) { - Timber.d("%s is not a calendar", member); - continue; - } - SupportedCalendarComponentSet supportedCalendarComponentSet = - member.get(SupportedCalendarComponentSet.class); - if (supportedCalendarComponentSet == null - || !supportedCalendarComponentSet.getSupportsTasks()) { - Timber.d("%s does not support tasks", member); - continue; - } - urls.add(member); - } - return urls; - } - - void deleteCollection() throws IOException, HttpException { - new DavResource(httpClient, httpUrl).delete(null, response -> null); - } - - String makeCollection(String displayName, int color) - throws IOException, XmlPullParserException, HttpException { - DavResource davResource = - new DavResource(httpClient, httpUrl.resolve(UUIDHelper.newUUID() + "/")); - String mkcolString = getMkcolString(displayName, color); - - davResource.mkCol(mkcolString, response -> null); - return davResource.getLocation().toString(); - } - - String updateCollection(String displayName, int color) - throws IOException, XmlPullParserException, HttpException { - PatchableDavResource davResource = new PatchableDavResource(httpClient, httpUrl); - davResource.propPatch(getPropPatchString(displayName, color), response -> null); - return davResource.getLocation().toString(); - } - - private String getPropPatchString(String displayName, int color) - throws IOException, XmlPullParserException { - XmlPullParserFactory xmlPullParserFactory = XmlPullParserFactory.newInstance(); - XmlSerializer xml = xmlPullParserFactory.newSerializer(); - StringWriter stringWriter = new StringWriter(); - xml.setOutput(stringWriter); - xml.startDocument("UTF-8", null); - xml.setPrefix("", NS_WEBDAV); - xml.setPrefix("CAL", NS_CALDAV); - xml.setPrefix("CARD", NS_CARDDAV); - xml.startTag(NS_WEBDAV, "propertyupdate"); - xml.startTag(XmlUtils.NS_WEBDAV, "set"); - xml.startTag(XmlUtils.NS_WEBDAV, "prop"); - setDisplayName(xml, displayName); - if (color != 0) { - setColor(xml, color); - } - xml.endTag(XmlUtils.NS_WEBDAV, "prop"); - xml.endTag(XmlUtils.NS_WEBDAV, "set"); - if (color == 0) { - xml.startTag(XmlUtils.NS_WEBDAV, "remove"); - xml.startTag(XmlUtils.NS_WEBDAV, "prop"); - xml.startTag(XmlUtils.NS_APPLE_ICAL, "calendar-color"); - xml.endTag(XmlUtils.NS_APPLE_ICAL, "calendar-color"); - xml.endTag(XmlUtils.NS_WEBDAV, "prop"); - xml.endTag(XmlUtils.NS_WEBDAV, "remove"); - } - xml.endTag(XmlUtils.NS_WEBDAV, "propertyupdate"); - xml.endDocument(); - xml.flush(); - return stringWriter.toString(); - } - - private String getMkcolString(String displayName, int color) throws IOException, XmlPullParserException { - XmlPullParserFactory xmlPullParserFactory = XmlPullParserFactory.newInstance(); - XmlSerializer xml = xmlPullParserFactory.newSerializer(); - StringWriter stringWriter = new StringWriter(); - xml.setOutput(stringWriter); - xml.startDocument("UTF-8", null); - xml.setPrefix("", NS_WEBDAV); - xml.setPrefix("CAL", NS_CALDAV); - xml.setPrefix("CARD", NS_CARDDAV); - xml.startTag(NS_WEBDAV, "mkcol"); - xml.startTag(XmlUtils.NS_WEBDAV, "set"); - xml.startTag(XmlUtils.NS_WEBDAV, "prop"); - xml.startTag(XmlUtils.NS_WEBDAV, "resourcetype"); - xml.startTag(XmlUtils.NS_WEBDAV, "collection"); - xml.endTag(XmlUtils.NS_WEBDAV, "collection"); - xml.startTag(XmlUtils.NS_CALDAV, "calendar"); - xml.endTag(XmlUtils.NS_CALDAV, "calendar"); - xml.endTag(XmlUtils.NS_WEBDAV, "resourcetype"); - setDisplayName(xml, displayName); - if (color != 0) { - setColor(xml, color); - } - xml.startTag(XmlUtils.NS_CALDAV, "supported-calendar-component-set"); - xml.startTag(XmlUtils.NS_CALDAV, "comp"); - xml.attribute(null, "name", "VTODO"); - xml.endTag(XmlUtils.NS_CALDAV, "comp"); - xml.endTag(XmlUtils.NS_CALDAV, "supported-calendar-component-set"); - xml.endTag(XmlUtils.NS_WEBDAV, "prop"); - xml.endTag(XmlUtils.NS_WEBDAV, "set"); - xml.endTag(XmlUtils.NS_WEBDAV, "mkcol"); - xml.endDocument(); - xml.flush(); - return stringWriter.toString(); - } - - private void setDisplayName(XmlSerializer xml, String name) throws IOException { - xml.startTag(XmlUtils.NS_WEBDAV, "displayname"); - xml.text(name); - xml.endTag(XmlUtils.NS_WEBDAV, "displayname"); - } - - private void setColor(XmlSerializer xml, int color) throws IOException { - xml.startTag(XmlUtils.NS_APPLE_ICAL, "calendar-color"); - xml.text(String.format("#%06X%02X", color & 0xFFFFFF, color >>> 24)); - xml.endTag(XmlUtils.NS_APPLE_ICAL, "calendar-color"); - } - - OkHttpClient getHttpClient() { - return httpClient; - } - - CaldavClient setForeground() { - foreground = true; - return this; - } -} diff --git a/app/src/main/java/org/tasks/caldav/CaldavClient.kt b/app/src/main/java/org/tasks/caldav/CaldavClient.kt new file mode 100644 index 000000000..30818417b --- /dev/null +++ b/app/src/main/java/org/tasks/caldav/CaldavClient.kt @@ -0,0 +1,334 @@ +package org.tasks.caldav + +import android.content.Context +import androidx.annotation.WorkerThread +import at.bitfire.cert4android.CustomCertManager +import at.bitfire.dav4jvm.BasicDigestAuthHandler +import at.bitfire.dav4jvm.DavResource +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_CARDDAV +import at.bitfire.dav4jvm.XmlUtils.NS_WEBDAV +import at.bitfire.dav4jvm.exception.DavException +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.dav4jvm.property.* +import at.bitfire.dav4jvm.property.ResourceType.Companion.CALENDAR +import com.todoroo.astrid.helper.UUIDHelper +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import org.tasks.DebugNetworkInterceptor +import org.tasks.R +import org.tasks.Strings.isNullOrEmpty +import org.tasks.data.CaldavAccount +import org.tasks.data.CaldavCalendar +import org.tasks.preferences.Preferences +import org.tasks.security.KeyStoreEncryption +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 java.util.* +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager + +class CaldavClient { + private val encryption: KeyStoreEncryption + private val preferences: Preferences + private val interceptor: DebugNetworkInterceptor + val httpClient: OkHttpClient? + private val httpUrl: HttpUrl? + private val context: Context + private val basicDigestAuthHandler: BasicDigestAuthHandler? + private var foreground = false + + @Inject + internal constructor( + @ApplicationContext context: Context, + encryption: KeyStoreEncryption, + preferences: Preferences, + interceptor: DebugNetworkInterceptor) { + this.context = context + this.encryption = encryption + this.preferences = preferences + this.interceptor = interceptor + httpClient = null + httpUrl = null + basicDigestAuthHandler = null + } + + private constructor( + context: Context, + encryption: KeyStoreEncryption, + preferences: Preferences, + interceptor: DebugNetworkInterceptor, + url: String?, + username: String, + password: String, + foreground: Boolean) { + this.context = context + this.encryption = encryption + this.preferences = preferences + this.interceptor = interceptor + val customCertManager = CustomCertManager(context) + customCertManager.appInForeground = foreground + val hostnameVerifier = customCertManager.hostnameVerifier(null) + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(null, arrayOf(customCertManager), null) + basicDigestAuthHandler = BasicDigestAuthHandler(null, username, password) + val builder = OkHttpClient() + .newBuilder() + .addNetworkInterceptor(basicDigestAuthHandler) + .authenticator(basicDigestAuthHandler) + .cookieJar(MemoryCookieStore()) + .followRedirects(false) + .followSslRedirects(true) + .sslSocketFactory(sslContext.socketFactory, customCertManager) + .hostnameVerifier(hostnameVerifier) + .connectTimeout(15, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + if (preferences.isFlipperEnabled) { + interceptor.add(builder) + } + httpClient = builder.build() + httpUrl = url?.toHttpUrlOrNull() + } + + @Throws(NoSuchAlgorithmException::class, KeyManagementException::class) + suspend fun forAccount(account: CaldavAccount): CaldavClient { + return forUrl(account.url, account.username!!, account.getPassword(encryption)) + } + + @Throws(NoSuchAlgorithmException::class, KeyManagementException::class) + suspend fun forCalendar(account: CaldavAccount, calendar: CaldavCalendar): CaldavClient { + return forUrl(calendar.url, account.username!!, account.getPassword(encryption)) + } + + @Throws(KeyManagementException::class, NoSuchAlgorithmException::class) + suspend fun forUrl(url: String?, username: String, password: String): CaldavClient = withContext(Dispatchers.IO) { + CaldavClient( + context, encryption, preferences, interceptor, url, username, password, foreground) + } + + @WorkerThread + @Throws(DavException::class, IOException::class) + private fun tryFindPrincipal(link: String): String? { + val url = httpUrl!!.resolve(link) + Timber.d("Checking for principal: %s", url) + val davResource = DavResource(httpClient!!, url!!) + val responses = ArrayList() + davResource.propfind(0, CurrentUserPrincipal.NAME) { response, _ -> + responses.add(response) + } + if (responses.isNotEmpty()) { + val response = responses[0] + val currentUserPrincipal = response[CurrentUserPrincipal::class.java] + if (currentUserPrincipal != null) { + val href = currentUserPrincipal.href + if (!isNullOrEmpty(href)) { + return href + } + } + } + return null + } + + @WorkerThread + @Throws(DavException::class, IOException::class) + private fun findHomeset(): String { + val davResource = DavResource(httpClient!!, httpUrl!!) + val responses = ArrayList() + davResource.propfind(0, CalendarHomeSet.NAME) { response, _ -> + responses.add(response) + } + val response = responses[0] + val calendarHomeSet = response[CalendarHomeSet::class.java] + ?: throw DisplayableException(R.string.caldav_home_set_not_found) + val hrefs: List = calendarHomeSet.hrefs + if (hrefs.size != 1) { + throw DisplayableException(R.string.caldav_home_set_not_found) + } + val homeSet = hrefs[0] + if (isNullOrEmpty(homeSet)) { + throw DisplayableException(R.string.caldav_home_set_not_found) + } + return davResource.location.resolve(homeSet).toString() + } + + @Throws(IOException::class, DavException::class, NoSuchAlgorithmException::class, KeyManagementException::class) + suspend fun homeSet(): 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("") + } + forUrl( + (if (isNullOrEmpty(principal)) httpUrl else httpUrl!!.resolve(principal!!)).toString(), + basicDigestAuthHandler!!.username, + basicDigestAuthHandler.password) + .findHomeset() + } + + @Throws(IOException::class, DavException::class) + suspend fun calendars(): List = withContext(Dispatchers.IO) { + val davResource = DavResource(httpClient!!, httpUrl!!) + val responses = ArrayList() + davResource.propfind( + 1, + ResourceType.NAME, + DisplayName.NAME, + SupportedCalendarComponentSet.NAME, + GetCTag.NAME, + CalendarColor.NAME, + SyncToken.NAME) { response: Response, relation: HrefRelation -> + if (relation == HrefRelation.MEMBER) { + responses.add(response) + } + } + val urls: MutableList = ArrayList() + for (member in responses) { + val resourceType = member[ResourceType::class.java] + if (resourceType == null + || !resourceType.types.contains(CALENDAR)) { + Timber.d("%s is not a calendar", member) + continue + } + val supportedCalendarComponentSet = member.get(SupportedCalendarComponentSet::class.java) + if (supportedCalendarComponentSet == null + || !supportedCalendarComponentSet.supportsTasks) { + Timber.d("%s does not support tasks", member) + continue + } + urls.add(member) + } + urls + } + + @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): String = withContext(Dispatchers.IO) { + val davResource = DavResource(httpClient!!, httpUrl!!.resolve(UUIDHelper.newUUID() + "/")!!) + val mkcolString = getMkcolString(displayName, color) + davResource.mkCol(mkcolString) {} + davResource.location.toString() + } + + @Throws(IOException::class, XmlPullParserException::class, HttpException::class) + suspend fun updateCollection(displayName: String, color: Int): String = withContext(Dispatchers.IO) { + val davResource = PatchableDavResource(httpClient!!, httpUrl!!) + davResource.propPatch(getPropPatchString(displayName, color)) {} + davResource.location.toString() + } + + @Throws(IOException::class, XmlPullParserException::class) + private fun getPropPatchString(displayName: String, color: Int): String { + val xmlPullParserFactory = XmlPullParserFactory.newInstance() + val xml = xmlPullParserFactory.newSerializer() + val stringWriter = StringWriter() + xml.setOutput(stringWriter) + xml.startDocument("UTF-8", null) + xml.setPrefix("", NS_WEBDAV) + xml.setPrefix("CAL", NS_CALDAV) + xml.setPrefix("CARD", NS_CARDDAV) + xml.startTag(NS_WEBDAV, "propertyupdate") + xml.startTag(NS_WEBDAV, "set") + xml.startTag(NS_WEBDAV, "prop") + setDisplayName(xml, displayName) + if (color != 0) { + setColor(xml, color) + } + xml.endTag(NS_WEBDAV, "prop") + xml.endTag(NS_WEBDAV, "set") + if (color == 0) { + xml.startTag(NS_WEBDAV, "remove") + xml.startTag(NS_WEBDAV, "prop") + xml.startTag(NS_APPLE_ICAL, "calendar-color") + xml.endTag(NS_APPLE_ICAL, "calendar-color") + xml.endTag(NS_WEBDAV, "prop") + xml.endTag(NS_WEBDAV, "remove") + } + xml.endTag(NS_WEBDAV, "propertyupdate") + xml.endDocument() + xml.flush() + return stringWriter.toString() + } + + @Throws(IOException::class, XmlPullParserException::class) + private fun getMkcolString(displayName: String, color: Int): String { + val xmlPullParserFactory = XmlPullParserFactory.newInstance() + val xml = xmlPullParserFactory.newSerializer() + val stringWriter = StringWriter() + xml.setOutput(stringWriter) + xml.startDocument("UTF-8", null) + xml.setPrefix("", NS_WEBDAV) + xml.setPrefix("CAL", NS_CALDAV) + xml.setPrefix("CARD", NS_CARDDAV) + xml.startTag(NS_WEBDAV, "mkcol") + xml.startTag(NS_WEBDAV, "set") + xml.startTag(NS_WEBDAV, "prop") + xml.startTag(NS_WEBDAV, "resourcetype") + xml.startTag(NS_WEBDAV, "collection") + xml.endTag(NS_WEBDAV, "collection") + xml.startTag(NS_CALDAV, "calendar") + xml.endTag(NS_CALDAV, "calendar") + xml.endTag(NS_WEBDAV, "resourcetype") + setDisplayName(xml, displayName) + if (color != 0) { + setColor(xml, color) + } + xml.startTag(NS_CALDAV, "supported-calendar-component-set") + xml.startTag(NS_CALDAV, "comp") + xml.attribute(null, "name", "VTODO") + xml.endTag(NS_CALDAV, "comp") + xml.endTag(NS_CALDAV, "supported-calendar-component-set") + xml.endTag(NS_WEBDAV, "prop") + xml.endTag(NS_WEBDAV, "set") + xml.endTag(NS_WEBDAV, "mkcol") + xml.endDocument() + xml.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") + } + + fun setForeground(): CaldavClient { + foreground = true + return this + } +} \ No newline at end of file diff --git a/app/src/main/java/org/tasks/caldav/CaldavSynchronizer.kt b/app/src/main/java/org/tasks/caldav/CaldavSynchronizer.kt index 874ef9db4..0dc32bc25 100644 --- a/app/src/main/java/org/tasks/caldav/CaldavSynchronizer.kt +++ b/app/src/main/java/org/tasks/caldav/CaldavSynchronizer.kt @@ -4,6 +4,7 @@ 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 import at.bitfire.dav4jvm.Response.HrefRelation import at.bitfire.dav4jvm.exception.DavException import at.bitfire.dav4jvm.exception.HttpException @@ -100,7 +101,7 @@ class CaldavSynchronizer @Inject constructor( @Throws(IOException::class, DavException::class, KeyManagementException::class, NoSuchAlgorithmException::class) private suspend fun synchronize(account: CaldavAccount) { val caldavClient = client.forAccount(account) - val resources = caldavClient.calendars + 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))) { @@ -126,7 +127,7 @@ class CaldavSynchronizer @Inject constructor( caldavDao.update(calendar) localBroadcastManager.broadcastRefreshList() } - sync(calendar, resource, caldavClient.httpClient) + sync(calendar, resource, caldavClient.httpClient!!) } setError(account, "") } @@ -141,7 +142,10 @@ class CaldavSynchronizer @Inject constructor( } @Throws(DavException::class) - private suspend fun sync(caldavCalendar: CaldavCalendar, resource: at.bitfire.dav4jvm.Response, httpClient: OkHttpClient) { + private suspend fun sync( + caldavCalendar: CaldavCalendar, + resource: Response, + httpClient: OkHttpClient) { Timber.d("sync(%s)", caldavCalendar) val httpUrl = resource.href pushLocalChanges(caldavCalendar, httpClient, httpUrl) @@ -159,9 +163,13 @@ class CaldavSynchronizer @Inject constructor( 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 members = ArrayList() + 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] if (eTag == null || isNullOrEmpty(eTag.eTag)) { return@filter false @@ -171,8 +179,12 @@ class CaldavSynchronizer @Inject constructor( } for (items in changed.chunked(30)) { val urls = items.map { it.href } - val responses = ResponseList(HrefRelation.MEMBER) - davCalendar.multiget(urls, responses) + val responses = ArrayList() + 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] @@ -260,12 +272,12 @@ class CaldavSynchronizer @Inject constructor( 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) + remote.put(requestBody, null, false) { + val getETag = fromResponse(it) + if (getETag != null && !isNullOrEmpty(getETag.eTag)) { + caldavTask.etag = getETag.eTag + caldavTask.vtodo = String(data) + } } } catch (e: HttpException) { Timber.e(e) diff --git a/app/src/main/java/org/tasks/caldav/Response.java b/app/src/main/java/org/tasks/caldav/Response.java deleted file mode 100644 index 67d2a9cc4..000000000 --- a/app/src/main/java/org/tasks/caldav/Response.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.tasks.caldav; - -import kotlin.Unit; -import kotlin.jvm.functions.Function1; - -class Response implements Function1 { - - private okhttp3.Response response; - - @Override - public Unit invoke(okhttp3.Response response) { - this.response = response; - return null; - } - - public okhttp3.Response get() { - return response; - } -} diff --git a/app/src/main/java/org/tasks/caldav/ResponseList.java b/app/src/main/java/org/tasks/caldav/ResponseList.java deleted file mode 100644 index ab3c093b8..000000000 --- a/app/src/main/java/org/tasks/caldav/ResponseList.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.tasks.caldav; - -import at.bitfire.dav4jvm.Response; -import at.bitfire.dav4jvm.Response.HrefRelation; -import java.util.ArrayList; -import kotlin.Unit; -import kotlin.jvm.functions.Function2; - -class ResponseList extends ArrayList - implements Function2 { - - private final HrefRelation filter; - - ResponseList() { - this(null); - } - - ResponseList(HrefRelation filter) { - this.filter = filter; - } - - @Override - public Unit invoke(Response response, HrefRelation hrefRelation) { - if (filter == null || hrefRelation == filter) { - add(response); - } - return null; - } -} diff --git a/app/src/main/java/org/tasks/caldav/UpdateCaldavAccountViewModel.kt b/app/src/main/java/org/tasks/caldav/UpdateCaldavAccountViewModel.kt index 3a7b0957c..27cad280f 100644 --- a/app/src/main/java/org/tasks/caldav/UpdateCaldavAccountViewModel.kt +++ b/app/src/main/java/org/tasks/caldav/UpdateCaldavAccountViewModel.kt @@ -5,7 +5,7 @@ import org.tasks.ui.CompletableViewModel class UpdateCaldavAccountViewModel @ViewModelInject constructor( private val client: CaldavClient) : CompletableViewModel() { - suspend fun updateCaldavAccount(url: String, username: String, password: String?) { - run { client.forUrl(url, username, password).homeSet } + suspend fun updateCaldavAccount(url: String, username: String, password: String) { + run { client.forUrl(url, username, password).homeSet() } } } \ No newline at end of file diff --git a/app/src/main/java/org/tasks/etesync/AddEteSyncAccountViewModel.kt b/app/src/main/java/org/tasks/etesync/AddEteSyncAccountViewModel.kt index 532db4009..20345c188 100644 --- a/app/src/main/java/org/tasks/etesync/AddEteSyncAccountViewModel.kt +++ b/app/src/main/java/org/tasks/etesync/AddEteSyncAccountViewModel.kt @@ -11,7 +11,7 @@ class AddEteSyncAccountViewModel @ViewModelInject constructor( run { client.setForeground() val token = client.forUrl(url, username, null, null).getToken(password) - Pair.create(client.forUrl(url, username, null, token!!).userInfo, token) + Pair.create(client.forUrl(url, username, null, token!!).userInfo(), token) } } } \ No newline at end of file diff --git a/app/src/main/java/org/tasks/etesync/EteSyncAccountSettingsActivity.kt b/app/src/main/java/org/tasks/etesync/EteSyncAccountSettingsActivity.kt index 57713db40..1aebab340 100644 --- a/app/src/main/java/org/tasks/etesync/EteSyncAccountSettingsActivity.kt +++ b/app/src/main/java/org/tasks/etesync/EteSyncAccountSettingsActivity.kt @@ -16,8 +16,6 @@ import com.etesync.journalmanager.UserInfoManager import com.todoroo.astrid.data.Task import com.todoroo.astrid.helper.UUIDHelper import dagger.hilt.android.AndroidEntryPoint -import io.reactivex.Completable -import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.launch import org.tasks.R import org.tasks.Strings.isNullOrEmpty @@ -121,7 +119,7 @@ class EteSyncAccountSettingsActivity : BaseCaldavAccountSettingsActivity(), Tool override suspend fun addAccount(url: String, username: String, password: String) = addAccountViewModel.addAccount(url, username, password) - override suspend fun updateAccount(url: String, username: String, password: String?) = + override suspend fun updateAccount(url: String, username: String, password: String) = updateAccountViewModel.updateAccount( url, username, @@ -170,11 +168,7 @@ class EteSyncAccountSettingsActivity : BaseCaldavAccountSettingsActivity(), Tool } override suspend fun removeAccount() { - if (caldavAccount != null) { - Completable.fromAction { eteSyncClient.forAccount(caldavAccount!!).invalidateToken() } - .subscribeOn(Schedulers.io()) - .subscribe() - } + caldavAccount?.let { eteSyncClient.forAccount(it).invalidateToken() } super.removeAccount() } diff --git a/app/src/main/java/org/tasks/etesync/EteSyncClient.kt b/app/src/main/java/org/tasks/etesync/EteSyncClient.kt index 9aef98bea..5abd44d42 100644 --- a/app/src/main/java/org/tasks/etesync/EteSyncClient.kt +++ b/app/src/main/java/org/tasks/etesync/EteSyncClient.kt @@ -17,6 +17,8 @@ import com.etesync.journalmanager.model.SyncEntry import com.etesync.journalmanager.util.TokenAuthenticator import com.google.common.collect.Lists import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient @@ -110,7 +112,7 @@ class EteSyncClient { } @Throws(NoSuchAlgorithmException::class, KeyManagementException::class) - fun forAccount(account: CaldavAccount): EteSyncClient { + suspend fun forAccount(account: CaldavAccount): EteSyncClient { return forUrl( account.url, account.username, @@ -119,8 +121,8 @@ class EteSyncClient { } @Throws(KeyManagementException::class, NoSuchAlgorithmException::class) - fun forUrl(url: String?, username: String?, encryptionPassword: String?, token: String?): EteSyncClient { - return EteSyncClient( + suspend fun forUrl(url: String?, username: String?, encryptionPassword: String?, token: String?): EteSyncClient = withContext(Dispatchers.IO) { + EteSyncClient( context, encryption, preferences, @@ -133,16 +135,15 @@ class EteSyncClient { } @Throws(IOException::class, Exceptions.HttpException::class) - fun getToken(password: String?): String? { - return JournalAuthenticator(httpClient!!, httpUrl!!).getAuthToken(username!!, password!!) + suspend fun getToken(password: String?): String? = withContext(Dispatchers.IO) { + JournalAuthenticator(httpClient!!, httpUrl!!).getAuthToken(username!!, password!!) } - @get:Throws(Exceptions.HttpException::class) - val userInfo: UserInfoManager.UserInfo? - get() { - val userInfoManager = UserInfoManager(httpClient!!, httpUrl!!) - return userInfoManager.fetch(username!!) - } + @Throws(Exceptions.HttpException::class) + suspend fun userInfo(): UserInfoManager.UserInfo? = withContext(Dispatchers.IO) { + val userInfoManager = UserInfoManager(httpClient!!, httpUrl!!) + userInfoManager.fetch(username!!) + } @Throws(VersionTooNewException::class, IntegrityException::class) fun getCrypto(userInfo: UserInfoManager.UserInfo?, journal: Journal): CryptoManager { @@ -174,7 +175,7 @@ class EteSyncClient { } @Throws(Exceptions.HttpException::class) - fun getCalendars(userInfo: UserInfoManager.UserInfo?): Map { + suspend fun getCalendars(userInfo: UserInfoManager.UserInfo?): Map = withContext(Dispatchers.IO) { val result: MutableMap = HashMap() for (journal in journalManager!!.list()) { val collection = convertJournalToCollection(userInfo, journal) @@ -187,7 +188,7 @@ class EteSyncClient { } } } - return result + result } @Throws(IntegrityException::class, Exceptions.HttpException::class, VersionTooNewException::class) @@ -195,7 +196,7 @@ class EteSyncClient { userInfo: UserInfoManager.UserInfo?, journal: Journal, calendar: CaldavCalendar, - callback: suspend (List>) -> Unit) { + callback: suspend (List>) -> Unit) = withContext(Dispatchers.IO) { val journalEntryManager = JournalEntryManager(httpClient!!, httpUrl!!, journal.uid!!) val crypto = getCrypto(userInfo, journal) var journalEntries: List @@ -208,7 +209,7 @@ class EteSyncClient { } @Throws(Exceptions.HttpException::class) - fun pushEntries(journal: Journal, entries: List?, remoteCtag: String?) { + suspend fun pushEntries(journal: Journal, entries: List?, remoteCtag: String?) = withContext(Dispatchers.IO) { var remoteCtag = remoteCtag val journalEntryManager = JournalEntryManager(httpClient!!, httpUrl!!, journal.uid!!) for (partition in Lists.partition(entries!!, MAX_PUSH)) { @@ -221,7 +222,7 @@ class EteSyncClient { foreground = true } - fun invalidateToken() { + suspend fun invalidateToken() = withContext(Dispatchers.IO) { try { JournalAuthenticator(httpClient!!, httpUrl!!).invalidateAuthToken(token!!) } catch (e: Exception) { @@ -230,7 +231,7 @@ class EteSyncClient { } @Throws(VersionTooNewException::class, IntegrityException::class, Exceptions.HttpException::class) - fun makeCollection(name: String?, color: Int): String { + suspend fun makeCollection(name: String?, color: Int): String = withContext(Dispatchers.IO) { val uid = Journal.genUid() val collectionInfo = CollectionInfo() collectionInfo.displayName = name @@ -240,29 +241,29 @@ class EteSyncClient { collectionInfo.color = if (color == 0) null else color val crypto = CryptoManager(collectionInfo.version, encryptionPassword!!, uid) journalManager!!.create(Journal(crypto, collectionInfo.toJson(), uid)) - return uid + uid } @Throws(VersionTooNewException::class, IntegrityException::class, Exceptions.HttpException::class) - fun updateCollection(calendar: CaldavCalendar, name: String?, color: Int): String? { + suspend fun updateCollection(calendar: CaldavCalendar, name: String?, color: Int): String? = withContext(Dispatchers.IO) { val uid = calendar.url val journal = journalManager!!.fetch(uid!!) - val userInfo = userInfo + val userInfo = userInfo() val crypto = getCrypto(userInfo, journal) val collectionInfo = convertJournalToCollection(userInfo, journal) collectionInfo!!.displayName = name collectionInfo.color = if (color == 0) null else color journalManager.update(Journal(crypto, collectionInfo.toJson(), uid)) - return uid + uid } @Throws(Exceptions.HttpException::class) - fun deleteCollection(calendar: CaldavCalendar) { + suspend fun deleteCollection(calendar: CaldavCalendar) = withContext(Dispatchers.IO) { journalManager!!.delete(Journal.fakeWithUid(calendar.url!!)) } @Throws(Exceptions.HttpException::class, VersionTooNewException::class, IntegrityException::class, IOException::class) - fun createUserInfo(derivedKey: String?) { + suspend fun createUserInfo(derivedKey: String?) = withContext(Dispatchers.IO) { val cryptoManager = CryptoManager(CURRENT_VERSION, derivedKey!!, "userInfo") val userInfo: UserInfoManager.UserInfo = generate(cryptoManager, username!!) UserInfoManager(httpClient!!, httpUrl!!).create(userInfo) diff --git a/app/src/main/java/org/tasks/etesync/EteSynchronizer.kt b/app/src/main/java/org/tasks/etesync/EteSynchronizer.kt index e0a28b992..cf9d5f8af 100644 --- a/app/src/main/java/org/tasks/etesync/EteSynchronizer.kt +++ b/app/src/main/java/org/tasks/etesync/EteSynchronizer.kt @@ -78,7 +78,7 @@ class EteSynchronizer @Inject constructor( @Throws(KeyManagementException::class, NoSuchAlgorithmException::class, Exceptions.HttpException::class, IntegrityException::class, VersionTooNewException::class) private suspend fun synchronize(account: CaldavAccount) { val client = client.forAccount(account) - val userInfo = client.userInfo + val userInfo = client.userInfo() val resources = client.getCalendars(userInfo) val uids: Set = resources.values.mapNotNull { it.uid }.toHashSet() Timber.d("Found uids: %s", uids) diff --git a/app/src/main/java/org/tasks/etesync/UpdateEteSyncAccountViewModel.kt b/app/src/main/java/org/tasks/etesync/UpdateEteSyncAccountViewModel.kt index e67a06639..46a93757c 100644 --- a/app/src/main/java/org/tasks/etesync/UpdateEteSyncAccountViewModel.kt +++ b/app/src/main/java/org/tasks/etesync/UpdateEteSyncAccountViewModel.kt @@ -12,10 +12,10 @@ class UpdateEteSyncAccountViewModel @ViewModelInject constructor( run { client.setForeground() if (isNullOrEmpty(pass)) { - Pair.create(client.forUrl(url, user, null, token).userInfo, token) + Pair.create(client.forUrl(url, user, null, token).userInfo(), token) } else { val newToken = client.forUrl(url, user, null, null).getToken(pass) - Pair.create(client.forUrl(url, user, null, newToken).userInfo, newToken) + Pair.create(client.forUrl(url, user, null, newToken).userInfo(), newToken) } } }