Don't upload event when calendar is read only (#587)

* Make readOnly a LocalCollection property

* Move readOnly detection to SyncManager

* Add readOnly state access to LocalCalendar

* Add not implemented error to readOnly state access of LocalJtxCollection

* Handle read-only state of calendar at dirty events upload

* Handle read-only state of calendar at processing of locally deleted events

* Remove todo and update kdoc

* Fix indenting

* Add read-only prop to LocalTestCollection

* Add read-only state access to LocalTaskList

* LocalTestCollection: don't set read-only

* Update ical4android (for new KDoc)

* Make LocalCollection readOnly-API read only and take value from content provider during populate()

* SyncManager: use readOnly direct from localCollection

* Lift resetDeleted up to LocalResource

* Override and use resetDeleted for LocalEvent

* Add resetDeleted to LocalJtxICalObject

* Add resetDeleted to LocalTask

* Add resetDeleted to LocalTask

* Add resetDeleted to LocalTestResource

* Provide default access level

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
This commit is contained in:
Sunik Kupfer 2024-02-27 13:00:40 +01:00 committed by GitHub
parent df2b7d2bd0
commit 86742f5b18
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 124 additions and 34 deletions

View File

@ -16,6 +16,9 @@ class LocalTestCollection: LocalCollection<LocalTestResource> {
val entries = mutableListOf<LocalTestResource>()
override val readOnly: Boolean
get() = throw NotImplementedError()
override fun findDeleted() = entries.filter { it.deleted }
override fun findDirty() = entries.filter { it.dirty }

View File

@ -34,5 +34,6 @@ class LocalTestResource: LocalResource<Any> {
override fun add() = throw NotImplementedError()
override fun update(data: Any) = throw NotImplementedError()
override fun delete() = throw NotImplementedError()
override fun resetDeleted() = throw NotImplementedError()
}

View File

@ -6,8 +6,4 @@ package at.bitfire.davdroid.resource
import at.bitfire.vcard4android.Contact
interface LocalAddress: LocalResource<Contact> {
fun resetDeleted()
}
interface LocalAddress: LocalResource<Contact>

View File

@ -91,6 +91,10 @@ class LocalCalendar private constructor(
override val title: String
get() = displayName ?: id.toString()
private var accessLevel: Int = Calendars.CAL_ACCESS_OWNER // assume full access if not specified
override val readOnly
get() = accessLevel <= Calendars.CAL_ACCESS_READ
override var lastSyncState: SyncState?
get() = provider.query(calendarSyncURI(), arrayOf(COLUMN_SYNC_STATE), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
@ -105,6 +109,11 @@ class LocalCalendar private constructor(
}
override fun populate(info: ContentValues) {
super.populate(info)
accessLevel = info.getAsInteger(Calendars.CALENDAR_ACCESS_LEVEL) ?: Calendars.CAL_ACCESS_OWNER
}
fun update(info: Collection, updateColor: Boolean) =
update(valuesFromCollectionInfo(info, updateColor))

View File

@ -17,6 +17,12 @@ interface LocalCollection<out T: LocalResource<*>> {
var lastSyncState: SyncState?
/**
* Whether the collection should be treated as read-only on sync.
* Stops uploading dirty events (Server side changes are still downloaded).
*/
val readOnly: Boolean
/**
* Finds local resources of this collection which have been marked as *deleted* by the user
* or an app acting on their behalf.

View File

@ -21,7 +21,6 @@ import at.bitfire.ical4android.Ical4Android
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import net.fortuna.ical4j.model.property.ProdId
import java.util.UUID
class LocalEvent: AndroidEvent, LocalResource<Event> {
companion object {
@ -51,6 +50,7 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
)
}
/**
* Finds the amount of direct instances this event has (without exceptions); used by [numInstances]
* to find the number of instances of exceptions.
@ -256,6 +256,10 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
this.flags = flags
}
override fun resetDeleted() {
val values = ContentValues(1).apply { put(Events.DELETED, 0) }
calendar.provider.update(eventSyncURI(), values, null, null)
}
object Factory: AndroidEventFactory<LocalEvent> {
override fun fromProvider(calendar: AndroidCalendar<*>, values: ContentValues) =
@ -263,3 +267,4 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
}
}

View File

@ -50,6 +50,9 @@ class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Lo
}
}
override val readOnly: Boolean
get() = throw NotImplementedError()
override val tag: String
get() = "jtx-${account.name}-$id"
override val title: String

View File

@ -47,4 +47,9 @@ class LocalJtxICalObject(
}
}
override fun resetDeleted() {
throw NotImplementedError()
}
}

View File

@ -90,4 +90,9 @@ interface LocalResource<in TData: Any> {
*/
fun delete(): Int
/**
* Undoes deletion of the data object from the content provider.
*/
fun resetDeleted()
}

View File

@ -104,6 +104,10 @@ class LocalTask: DmfsTask, LocalResource<Task> {
this.flags = flags
}
override fun resetDeleted() {
throw NotImplementedError()
}
object Factory: DmfsTaskFactory<LocalTask> {
override fun fromProvider(taskList: DmfsTaskList<*>, values: ContentValues) =

View File

@ -68,6 +68,12 @@ class LocalTaskList private constructor(
}
private var accessLevel: Int = TaskListColumns.ACCESS_LEVEL_UNDEFINED
override val readOnly
get() =
accessLevel != TaskListColumns.ACCESS_LEVEL_UNDEFINED &&
accessLevel <= TaskListColumns.ACCESS_LEVEL_READ
override val tag: String
get() = "tasks-${account.name}-$id"
@ -96,8 +102,13 @@ class LocalTaskList private constructor(
}
override fun populate(values: ContentValues) {
super.populate(values)
accessLevel = values.getAsInteger(TaskListColumns.ACCESS_LEVEL)
}
fun update(info: Collection, updateColor: Boolean) =
update(valuesFromCollectionInfo(info, updateColor))
update(valuesFromCollectionInfo(info, updateColor))
override fun findDeleted() = queryTasks(Tasks._DELETED, null)

View File

@ -73,29 +73,74 @@ class CalendarSyncManager(
}
override fun queryCapabilities(): SyncState? =
remoteExceptionContext {
var syncState: SyncState? = null
it.propfind(0, MaxResourceSize.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
if (relation == Response.HrefRelation.SELF) {
response[MaxResourceSize::class.java]?.maxSize?.let { maxSize ->
Logger.log.info("Calendar accepts events up to ${FileUtils.byteCountToDisplaySize(maxSize)}")
}
response[SupportedReportSet::class.java]?.let { supported ->
hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION)
}
syncState = syncState(response)
remoteExceptionContext {
var syncState: SyncState? = null
it.propfind(0, MaxResourceSize.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
if (relation == Response.HrefRelation.SELF) {
response[MaxResourceSize::class.java]?.maxSize?.let { maxSize ->
Logger.log.info("Calendar accepts events up to ${FileUtils.byteCountToDisplaySize(maxSize)}")
}
}
Logger.log.info("Calendar supports Collection Sync: $hasCollectionSync")
syncState
response[SupportedReportSet::class.java]?.let { supported ->
hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION)
}
syncState = syncState(response)
}
}
override fun syncAlgorithm() = if (accountSettings.getTimeRangePastDays() != null || !hasCollectionSync)
SyncAlgorithm.PROPFIND_REPORT
else
SyncAlgorithm.COLLECTION_SYNC
Logger.log.info("Calendar supports Collection Sync: $hasCollectionSync")
syncState
}
override fun syncAlgorithm() =
if (accountSettings.getTimeRangePastDays() != null || !hasCollectionSync)
SyncAlgorithm.PROPFIND_REPORT
else
SyncAlgorithm.COLLECTION_SYNC
override fun processLocallyDeleted(): Boolean {
if (localCollection.readOnly) {
var modified = false
for (event in localCollection.findDeleted()) {
Logger.log.warning("Restoring locally deleted event (read-only calendar!)")
localExceptionContext(event) { it.resetDeleted() }
modified = true
}
// This is unfortunately ugly: When an event has been inserted to a read-only calendar
// it's not enough to force synchronization (by returning true),
// but we also need to make sure all events are downloaded again.
if (modified)
localCollection.lastSyncState = null
return modified
}
// mirror deletions to remote collection (DELETE)
return super.processLocallyDeleted()
}
override fun uploadDirty(): Boolean {
var modified = false
if (localCollection.readOnly) {
for (event in localCollection.findDirty()) {
Logger.log.warning("Resetting locally modified event to ETag=null (read-only calendar!)")
localExceptionContext(event) { it.clearDirty(null, null) }
modified = true
}
// This is unfortunately ugly: When an event has been inserted to a read-only calendar
// it's not enough to force synchronization (by returning true),
// but we also need to make sure all events are downloaded again.
if (modified)
localCollection.lastSyncState = null
}
// generate UID/file name for newly created events
val superModified = super.uploadDirty()
// return true when any operation returned true
return modified or superModified
}
override fun generateUpload(resource: LocalEvent): RequestBody = localExceptionContext(resource) {
val event = requireNotNull(resource.event)
@ -143,8 +188,7 @@ class CalendarSyncManager(
}
}
override fun postProcess() {
}
override fun postProcess() {}
// helpers
@ -195,6 +239,6 @@ class CalendarSyncManager(
}
override fun notifyInvalidResourceTitle(): String =
context.getString(R.string.sync_invalid_event)
context.getString(R.string.sync_invalid_event)
}

View File

@ -105,8 +105,6 @@ class ContactsSyncManager(
infix fun <T> Set<T>.disjunct(other: Set<T>) = (this - other) union (other - this)
}
private val readOnly = localAddressBook.readOnly
private var hasVCard4 = false
private var hasJCard = false
private val groupStrategy = when (accountSettings.getGroupMethod()) {
@ -177,7 +175,7 @@ class ContactsSyncManager(
SyncAlgorithm.PROPFIND_REPORT
override fun processLocallyDeleted() =
if (readOnly) {
if (localCollection.readOnly) {
var modified = false
for (group in localCollection.findDeletedGroups()) {
Logger.log.warning("Restoring locally deleted group (read-only address book!)")
@ -205,7 +203,7 @@ class ContactsSyncManager(
override fun uploadDirty(): Boolean {
var modified = false
if (readOnly) {
if (localCollection.readOnly) {
for (group in localCollection.findDirtyGroups()) {
Logger.log.warning("Resetting locally modified group to ETag=null (read-only address book!)")
localExceptionContext(group) { it.clearDirty(null, null) }

View File

@ -28,7 +28,7 @@ androidx-work = "2.9.0"
appIntro = "7.0.0-beta02"
bitfire-cert4android = "f0964cb"
bitfire-dav4jvm = "b30913f"
bitfire-ical4android = "998f6b6"
bitfire-ical4android = "31650d1"
bitfire-vcard4android = "03ef99a"
commons-collections = "4.4"
commons-lang = "3.14.0"