Synchronize list icons with CalDAV

pull/3756/head
Alex Baker 5 months ago
parent c5f8583146
commit 3a37d6481e

@ -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<UpgradeIconSyncWork>()
.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 {

@ -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,

@ -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,

@ -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()
}
},
)
}
}
}
}

@ -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
}
}
}
}

@ -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
}
}
}

@ -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())
}
}

@ -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,
)
)

@ -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
}
}

@ -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 <B : WorkRequest.Builder<B, *>, W : WorkRequest> WorkRequest.Builder<B, W>.setInputData(
vararg pairs: Pair<String, Any?>
): B = setInputData(workDataOf(*pairs))
val networkConstraints: Constraints
get() = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()

Loading…
Cancel
Save