diff --git a/app/src/main/java/com/todoroo/astrid/service/Upgrader.kt b/app/src/main/java/com/todoroo/astrid/service/Upgrader.kt index 5f37f4513..829bffc3b 100644 --- a/app/src/main/java/com/todoroo/astrid/service/Upgrader.kt +++ b/app/src/main/java/com/todoroo/astrid/service/Upgrader.kt @@ -3,6 +3,9 @@ package com.todoroo.astrid.service import android.content.Context import android.net.Uri import androidx.annotation.ColorRes +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager import com.google.common.collect.ImmutableListMultimap import com.google.common.collect.ListMultimap import com.google.common.collect.Multimaps @@ -34,6 +37,8 @@ import org.tasks.data.entity.Filter import org.tasks.data.entity.Tag import org.tasks.data.entity.TagData import org.tasks.filters.CaldavFilter +import org.tasks.jobs.UpgradeIconSyncWork +import org.tasks.jobs.networkConstraints import org.tasks.preferences.DefaultFilterProvider import org.tasks.preferences.Preferences import org.tasks.time.DateTimeUtils2.currentTimeMillis @@ -145,6 +150,15 @@ class Upgrader @Inject constructor( } } } + run(from, V14_8) { + WorkManager.getInstance(context).enqueueUniqueWork( + uniqueWorkName = "upload_icons", + existingWorkPolicy = ExistingWorkPolicy.KEEP, + request = OneTimeWorkRequestBuilder() + .setConstraints(networkConstraints) + .build() + ) + } preferences.setBoolean(R.string.p_just_updated, true) } else { setInstallDetails(to) @@ -407,6 +421,7 @@ class Upgrader @Inject constructor( const val V12_8 = 120800 const val V14_5_4 = 140516 const val V14_6_1 = 140602 + const val V14_8 = 140800 @JvmStatic fun getAndroidColor(context: Context, index: Int): Int { diff --git a/app/src/main/java/org/tasks/caldav/BaseCaldavCalendarSettingsActivity.kt b/app/src/main/java/org/tasks/caldav/BaseCaldavCalendarSettingsActivity.kt index e4ffbde24..38567cb3f 100644 --- a/app/src/main/java/org/tasks/caldav/BaseCaldavCalendarSettingsActivity.kt +++ b/app/src/main/java/org/tasks/caldav/BaseCaldavCalendarSettingsActivity.kt @@ -78,11 +78,10 @@ abstract class BaseCaldavCalendarSettingsActivity : BaseListSettingsActivity() { showProgressIndicator() createCalendar(caldavAccount, name, baseViewModel.color) } - nameChanged() || colorChanged() -> { + nameChanged() || colorChanged() || iconChanged() -> { showProgressIndicator() updateNameAndColor(caldavAccount, caldavCalendar!!, name, baseViewModel.color) } - iconChanged() -> updateCalendar() else -> finish() } } @@ -150,7 +149,7 @@ abstract class BaseCaldavCalendarSettingsActivity : BaseListSettingsActivity() { ) caldavDao.update(result) setResult( - Activity.RESULT_OK, + RESULT_OK, Intent(TaskListFragment.ACTION_RELOAD) .putExtra( MainActivity.OPEN_FILTER, diff --git a/app/src/main/java/org/tasks/caldav/CaldavCalendarViewModel.kt b/app/src/main/java/org/tasks/caldav/CaldavCalendarViewModel.kt index c7f30feca..b6b4ce3a7 100644 --- a/app/src/main/java/org/tasks/caldav/CaldavCalendarViewModel.kt +++ b/app/src/main/java/org/tasks/caldav/CaldavCalendarViewModel.kt @@ -37,7 +37,7 @@ class CaldavCalendarViewModel @Inject constructor( ): CaldavCalendar? = doRequest { val url = withContext(Dispatchers.IO) { - provider.forAccount(caldavAccount).makeCollection(name, color) + provider.forAccount(caldavAccount).makeCollection(name, color, icon) } val calendar = CaldavCalendar( uuid = UUIDHelper.newUUID(), @@ -67,7 +67,7 @@ class CaldavCalendarViewModel @Inject constructor( ) = doRequest { withContext(Dispatchers.IO) { - provider.forAccount(account, calendar.url!!).updateCollection(name, color) + provider.forAccount(account, calendar.url!!).updateCollection(name, color, icon) } val result = calendar.copy( name = name, diff --git a/app/src/main/java/org/tasks/caldav/CaldavClient.kt b/app/src/main/java/org/tasks/caldav/CaldavClient.kt index 2193c87d6..ecc2ed6cb 100644 --- a/app/src/main/java/org/tasks/caldav/CaldavClient.kt +++ b/app/src/main/java/org/tasks/caldav/CaldavClient.kt @@ -11,9 +11,16 @@ 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.* +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 org.tasks.data.UUIDHelper +import at.bitfire.dav4jvm.property.SupportedCalendarComponentSet +import at.bitfire.dav4jvm.property.SyncToken import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.HttpUrl @@ -23,11 +30,13 @@ 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 @@ -122,33 +131,44 @@ open class CaldavClient( } @Throws(IOException::class, XmlPullParserException::class, HttpException::class) - suspend fun makeCollection(displayName: String, color: Int): String = withContext(Dispatchers.IO) { + 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): String = + suspend fun updateCollection(displayName: String, color: Int, icon: String?): String = withContext(Dispatchers.IO) { with(DavResource(httpClient, httpUrl!!)) { - proppatch( - setProperties = mutableMapOf(DisplayName.NAME to displayName).apply { - if (color != 0) { - put( - CalendarColor.NAME, - String.format("#%06X%02X", color and 0xFFFFFF, color ushr 24) - ) - } - }, - removeProperties = if (color == 0) listOf(CalendarColor.NAME) else emptyList(), - callback = { _, _ -> }, - ) + 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() @@ -286,18 +306,19 @@ open class CaldavClient( 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, + 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( @@ -313,5 +334,22 @@ open class CaldavClient( 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() + } + }, + ) + } } -} \ 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 ac836cf33..2bff37d24 100644 --- a/app/src/main/java/org/tasks/caldav/CaldavSynchronizer.kt +++ b/app/src/main/java/org/tasks/caldav/CaldavSynchronizer.kt @@ -38,12 +38,12 @@ 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.CalendarIcon 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.NOT_SHARED import org.tasks.caldav.property.ShareAccess.Companion.NO_ACCESS @@ -154,8 +154,10 @@ class CaldavSynchronizer @Inject constructor( 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 = resource[CalendarColor::class.java]?.color ?: 0 val access = resource.accessLevel + val icon = resource[CalendarIcon::class.java]?.icon?.takeIf { it.isNotBlank() } + if (access == ACCESS_UNKNOWN) { firebase.logEvent( R.string.event_sync_unknown_access, @@ -163,7 +165,6 @@ class CaldavSynchronizer @Inject constructor( (resource[ShareAccess::class.java]?.access?.toString() ?: "???") ) } - val color = calendarColor?.color ?: 0 if (calendar == null) { calendar = CaldavCalendar( name = remoteName, @@ -172,15 +173,20 @@ class CaldavSynchronizer @Inject constructor( uuid = UUIDHelper.newUUID(), color = color, access = access, + icon = icon, ) caldavDao.insert(calendar) } else if (calendar.name != remoteName - || calendar.color != color - || calendar.access != access + || calendar.color != color + || calendar.access != access + || (icon != null && calendar.icon != icon) ) { - calendar.color = color - calendar.name = remoteName - calendar.access = access + calendar = calendar.copy( + color = color, + name = remoteName, + access = access, + icon = icon ?: calendar.icon, + ) caldavDao.update(calendar) localBroadcastManager.broadcastRefreshList() } @@ -435,10 +441,13 @@ class CaldavSynchronizer @Inject constructor( fun registerFactories() { PropertyRegistry.register( - ShareAccess.Factory(), - Invite.Factory(), - OCOwnerPrincipal.Factory(), - OCInvite.Factory(), + listOf( + ShareAccess.Factory(), + Invite.Factory(), + OCOwnerPrincipal.Factory(), + OCInvite.Factory(), + CalendarIcon.Factory, + ) ) } @@ -487,4 +496,4 @@ class CaldavSynchronizer @Inject constructor( else -> INVITE_UNKNOWN } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/tasks/caldav/property/CalendarIcon.kt b/app/src/main/java/org/tasks/caldav/property/CalendarIcon.kt new file mode 100644 index 000000000..4bf214091 --- /dev/null +++ b/app/src/main/java/org/tasks/caldav/property/CalendarIcon.kt @@ -0,0 +1,32 @@ +package org.tasks.caldav.property + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser +import timber.log.Timber + +data class CalendarIcon( + val icon: String, +): Property { + companion object Companion { + @JvmField + val NAME = Property.Name(PropertyUtils.NS_TASKS, "x-calendar-icon") + } + + object Factory: PropertyFactory { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser): CalendarIcon? { + XmlUtils.readText(parser)?.takeIf { it.isNotBlank() }?.let { + try { + return CalendarIcon(it) + } catch (e: IllegalArgumentException) { + Timber.e(e, "Couldn't parse icon: $it") + } + } + return null + } + } +} diff --git a/app/src/main/java/org/tasks/caldav/property/PropertyUtils.kt b/app/src/main/java/org/tasks/caldav/property/PropertyUtils.kt index 67e9a837a..13c3359e7 100644 --- a/app/src/main/java/org/tasks/caldav/property/PropertyUtils.kt +++ b/app/src/main/java/org/tasks/caldav/property/PropertyUtils.kt @@ -1,10 +1,6 @@ package org.tasks.caldav.property -import at.bitfire.dav4jvm.PropertyFactory -import at.bitfire.dav4jvm.PropertyRegistry - object PropertyUtils { + const val NS_TASKS = "http://org.tasks/ns/" const val NS_OWNCLOUD = "http://owncloud.org/ns" - - fun PropertyRegistry.register(vararg factories: PropertyFactory) = register(factories.toList()) -} \ No newline at end of file +} diff --git a/app/src/main/java/org/tasks/jobs/MigrateLocalWork.kt b/app/src/main/java/org/tasks/jobs/MigrateLocalWork.kt index bd24b8ce2..c314037a9 100644 --- a/app/src/main/java/org/tasks/jobs/MigrateLocalWork.kt +++ b/app/src/main/java/org/tasks/jobs/MigrateLocalWork.kt @@ -34,7 +34,7 @@ class MigrateLocalWork @AssistedInject constructor( caldavDao.getCalendarsByAccount(fromAccount.uuid!!).forEach { caldavDao.update( it.copy( - url = caldavClient.makeCollection(it.name!!, it.color), + url = caldavClient.makeCollection(it.name!!, it.color, it.icon), account = caldavAccount.uuid, ) ) diff --git a/app/src/main/java/org/tasks/jobs/UpgradeIconSyncWork.kt b/app/src/main/java/org/tasks/jobs/UpgradeIconSyncWork.kt new file mode 100644 index 000000000..187a922fb --- /dev/null +++ b/app/src/main/java/org/tasks/jobs/UpgradeIconSyncWork.kt @@ -0,0 +1,55 @@ +package org.tasks.jobs + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import org.tasks.analytics.Firebase +import org.tasks.caldav.CaldavClientProvider +import org.tasks.caldav.property.CalendarIcon +import org.tasks.data.dao.CaldavDao +import org.tasks.data.entity.CaldavAccount +import org.tasks.injection.BaseWorker +import timber.log.Timber + +@HiltWorker +class UpgradeIconSyncWork @AssistedInject constructor( + @Assisted context: Context, + @Assisted workerParams: WorkerParameters, + firebase: Firebase, + private val clientProvider: CaldavClientProvider, + private val caldavDao: CaldavDao, +) : BaseWorker(context, workerParams, firebase) { + override suspend fun run(): Result { + var response = Result.success() + caldavDao + .getAccounts(CaldavAccount.TYPE_TASKS, CaldavAccount.TYPE_CALDAV) + .forEach { account -> + Timber.d("Uploading icons for $account") + val caldavClient = clientProvider.forAccount(account) + caldavClient.calendars().forEach { remote -> + val url = remote.href + val calendar = caldavDao + .getCalendarByUrl(account.uuid!!, url.toString()) + ?.takeIf { !it.readOnly() && it.icon?.isNotBlank() == true } + ?: run { + Timber.d("No icon set for $url") + return@forEach + } + val icon = remote[CalendarIcon::class.java]?.icon + if (icon?.isNotBlank() == true) { + Timber.d("Remote icon already set for $url") + return@forEach + } + Timber.d("Uploading icon to ${calendar.icon} for $url") + caldavClient.updateIcon( + url = url, + icon = calendar.icon, + onFailure = { response = Result.retry() } + ) + } + } + return response + } +} diff --git a/app/src/main/java/org/tasks/jobs/WorkManagerImpl.kt b/app/src/main/java/org/tasks/jobs/WorkManagerImpl.kt index 56d42a242..f557ce579 100644 --- a/app/src/main/java/org/tasks/jobs/WorkManagerImpl.kt +++ b/app/src/main/java/org/tasks/jobs/WorkManagerImpl.kt @@ -207,9 +207,6 @@ class WorkManagerImpl( enqueue(builder) } - private val networkConstraints: Constraints - get() = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() - override fun updatePurchases() = enqueueUnique(TAG_UPDATE_PURCHASES, UpdatePurchaseWork::class.java) @@ -260,3 +257,6 @@ class WorkManagerImpl( private fun , W : WorkRequest> WorkRequest.Builder.setInputData( vararg pairs: Pair ): B = setInputData(workDataOf(*pairs)) + +val networkConstraints: Constraints + get() = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()