Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Unverified Commit de24b0ac authored by Patrick Lang's avatar Patrick Lang Committed by GitHub
Browse files

jtx Board 2.3 with improved recur handling (#79)

* Including recur instances in generated ics

* Exclude recur instances from query dirty and query deleted
code format updates
removed deprecated method updateRelatedTo()

* Fixed bug in recuid handling on ical generation

* added queryRecur(...) and excluded recur instances when using query by filename

* updated getICSForCollection() to exclude unchanged recur instances

* updated minVersion for jtx Board

* Fixed:  export all from collection includes the recurring instances twice
parent 1c6bcb3f
Loading
Loading
Loading
Loading
+53 −99
Original line number Diff line number Diff line
@@ -102,7 +102,12 @@ open class JtxCollection<out T: JtxICalObject>(val account: Account,
     */
    fun queryDeletedICalObjects(): List<ContentValues> {
        val values = mutableListOf<ContentValues>()
        client.query(JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), null, "${JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID} = ? AND ${JtxContract.JtxICalObject.DELETED} = ?", arrayOf(id.toString(), "1"), null).use { cursor ->
        client.query(
            JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account),
            null,
            "${JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID} = ? AND ${JtxContract.JtxICalObject.DELETED} = ? AND ${JtxContract.JtxICalObject.RECURID} IS NULL", arrayOf(id.toString(), "1"),
            null
        ).use { cursor ->
            Ical4Android.log.fine("findDeleted: found ${cursor?.count} deleted records in ${account.name}")
            while (cursor?.moveToNext() == true) {
                values.add(cursor.toValues())
@@ -117,7 +122,12 @@ open class JtxCollection<out T: JtxICalObject>(val account: Account,
     */
    fun queryDirtyICalObjects(): List<ContentValues> {
        val values = mutableListOf<ContentValues>()
        client.query(JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), null, "${JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID} = ? AND ${JtxContract.JtxICalObject.DIRTY} = ?", arrayOf(id.toString(), "1"), null).use { cursor ->
        client.query(
            JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account),
            null,
            "${JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID} = ? AND ${JtxContract.JtxICalObject.DIRTY} = ? AND ${JtxContract.JtxICalObject.RECURID} IS NULL", arrayOf(id.toString(), "1"),
            null
        ).use { cursor ->
            Ical4Android.log.fine("findDirty: found ${cursor?.count} dirty records in ${account.name}")
            while (cursor?.moveToNext() == true) {
                values.add(cursor.toValues())
@@ -131,7 +141,12 @@ open class JtxCollection<out T: JtxICalObject>(val account: Account,
     * @return Content Values of the found item with the given filename or null if the result was empty or more than 1
     */
    fun queryByFilename(filename: String): ContentValues? {
        client.query(JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), null, "${JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID} = ? AND ${JtxContract.JtxICalObject.FILENAME} = ?", arrayOf(id.toString(), filename), null).use { cursor ->
        client.query(
            JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account),
            null,
            "${JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID} = ? AND ${JtxContract.JtxICalObject.FILENAME} = ? AND ${JtxContract.JtxICalObject.RECURID} IS NULL", arrayOf(id.toString(), filename),
            null
        ).use { cursor ->
            Ical4Android.log.fine("queryByFilename: found ${cursor?.count} records in ${account.name}")
            if (cursor?.count != 1)
                return null
@@ -155,6 +170,28 @@ open class JtxCollection<out T: JtxICalObject>(val account: Account,
        }
    }


    /**
     * @param [uid] of the entry that should be retrieved as content values
     * @return Content Values of the found item with the given UID or null if the result was empty or more than 1
     * The query checks for the [uid] within all collections of this account, not only the current collection.
     */
    fun queryRecur(uid: String, recurid: String, dtstart: Long): ContentValues? {
        client.query(
            JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account),
            null,
            "${JtxContract.JtxICalObject.UID} = ? AND ${JtxContract.JtxICalObject.RECURID} = ? AND ${JtxContract.JtxICalObject.DTSTART} = ?",
            arrayOf(uid, recurid, dtstart.toString()),
            null
        ).use { cursor ->
            Ical4Android.log.fine("queryByUID: found ${cursor?.count} records in ${account.name}")
            if (cursor?.count != 1)
                return null
            cursor.moveToFirst()
            return cursor.toValues()
        }
    }

    /**
     * updates the flags of all entries in the collection with the given flag
     * @param [flags] to be set
@@ -163,7 +200,12 @@ open class JtxCollection<out T: JtxICalObject>(val account: Account,
    fun updateSetFlags(flags: Int): Int {
        val values = ContentValues(1)
        values.put(JtxContract.JtxICalObject.FLAGS, flags)
        return client.update(JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), values, "${JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID} = ? AND ${JtxContract.JtxICalObject.DIRTY} = ?", arrayOf(id.toString(), "0"))
        return client.update(
            JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account),
            values,
            "${JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID} = ? AND ${JtxContract.JtxICalObject.DIRTY} = ?",
            arrayOf(id.toString(), "0")
        )
    }

    /**
@@ -188,105 +230,17 @@ open class JtxCollection<out T: JtxICalObject>(val account: Account,
    }


    /**
     * This function updates the Related-To relations in jtx Board.
     * STEP 1: find entries to update (all entries with 0 in related-to). When inserting the relation, we only know the parent iCalObjectId and the related UID (but not the related iCalObjectId).
     *         In this step we search for all Related-To relations where the LINKEDICALOBJEC_ID is not set, resolve it through the UID and set it.
     * STEP 2/3: jtx Board saves the relations in both directions, the Parent has an entry for his Child, the Child has an entry for his Parent. Step 2 and Step 3 make sure, that the Child-Parent pair is
     *         present in both directions.
     */
    @Deprecated("Moved to jtx Board content provider (function updateRelatedTo()). This function here will be deleted in one of the next versions.")
    fun updateRelatedTo() {
        // STEP 1: first find entries to update (all entries with 0 in related-to)
        client.query(JtxContract.JtxRelatedto.CONTENT_URI.asSyncAdapter(account), arrayOf(JtxContract.JtxRelatedto.TEXT), "${JtxContract.JtxRelatedto.LINKEDICALOBJECT_ID} = ?", arrayOf("0"), null).use {
            while(it?.moveToNext() == true) {
                val uid2upddate = it.getString(0)

                client.query(JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), arrayOf(JtxContract.JtxICalObject.ID), "${JtxContract.JtxICalObject.UID} = ?", arrayOf(uid2upddate), null).use { idOfthisUidCursor ->
                    if (idOfthisUidCursor?.moveToFirst() == true) {
                        val idOfthisUid = idOfthisUidCursor.getLong(0)

                        val updateContentValues = ContentValues()
                        updateContentValues.put(JtxContract.JtxRelatedto.LINKEDICALOBJECT_ID, idOfthisUid)
                        client.update(JtxContract.JtxRelatedto.CONTENT_URI.asSyncAdapter(account), updateContentValues,"${JtxContract.JtxRelatedto.TEXT} = ? AND ${JtxContract.JtxRelatedto.LINKEDICALOBJECT_ID} = ?", arrayOf(uid2upddate, "0")
                        )
                    }
                }
            }
        }


        // STEP 2: query all related to that are linking their PARENTS and check if they also have the opposite relationship entered, if not, then add it
        client.query(JtxContract.JtxRelatedto.CONTENT_URI.asSyncAdapter(account), arrayOf(JtxContract.JtxRelatedto.ICALOBJECT_ID, JtxContract.JtxRelatedto.LINKEDICALOBJECT_ID, JtxContract.JtxRelatedto.RELTYPE), "${JtxContract.JtxRelatedto.RELTYPE} = ?", arrayOf(JtxContract.JtxRelatedto.Reltype.PARENT.name), null).use {
                cursorAllLinkedParents ->
            while (cursorAllLinkedParents?.moveToNext() == true) {
                val childId = cursorAllLinkedParents.getString(0)
                val parentId = cursorAllLinkedParents.getString(1)

                client.query(JtxContract.JtxRelatedto.CONTENT_URI.asSyncAdapter(account), arrayOf(JtxContract.JtxRelatedto.ICALOBJECT_ID, JtxContract.JtxRelatedto.LINKEDICALOBJECT_ID, JtxContract.JtxRelatedto.RELTYPE), "${JtxContract.JtxRelatedto.ICALOBJECT_ID} = ? AND ${JtxContract.JtxRelatedto.LINKEDICALOBJECT_ID} = ? AND ${JtxContract.JtxRelatedto.RELTYPE} = ?", arrayOf(parentId.toString(), childId.toString(), JtxContract.JtxRelatedto.Reltype.CHILD.name), null).use {  cursor ->
                    // if the query does not bring any result, then we insert the opposite relationship
                    if (cursor?.moveToFirst() == false) {
                        //get the UID of the linked entry
                        client.query(JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), arrayOf(JtxContract.JtxICalObject.UID), "${JtxContract.JtxICalObject.ID} = ?", arrayOf(childId.toString()), null).use {
                                foundIcalObjectCursor ->

                            if (foundIcalObjectCursor?.moveToFirst() == true) {
                                val childUID = foundIcalObjectCursor.getString(0)
                                val cv = ContentValues().apply {
                                    put(JtxContract.JtxRelatedto.ICALOBJECT_ID, parentId)
                                    put(JtxContract.JtxRelatedto.LINKEDICALOBJECT_ID, childId)
                                    put(JtxContract.JtxRelatedto.RELTYPE, JtxContract.JtxRelatedto.Reltype.CHILD.name)
                                    put(JtxContract.JtxRelatedto.TEXT, childUID)
                                }
                                client.insert(JtxContract.JtxRelatedto.CONTENT_URI.asSyncAdapter(account), cv)
                            }
                        }
                    }
                }
            }
        }


        // STEP 3: query all related to that are linking their CHILD and check if they also have the opposite relationship entered, if not, then add it
        client.query(JtxContract.JtxRelatedto.CONTENT_URI.asSyncAdapter(account), arrayOf(JtxContract.JtxRelatedto.ICALOBJECT_ID, JtxContract.JtxRelatedto.LINKEDICALOBJECT_ID, JtxContract.JtxRelatedto.RELTYPE), "${JtxContract.JtxRelatedto.RELTYPE} = ?", arrayOf(JtxContract.JtxRelatedto.Reltype.CHILD.name), null).use {
                cursorAllLinkedParents ->
            while (cursorAllLinkedParents?.moveToNext() == true) {

                val parentId = cursorAllLinkedParents.getLong(0)
                val childId = cursorAllLinkedParents.getLong(1)

                client.query(JtxContract.JtxRelatedto.CONTENT_URI.asSyncAdapter(account), arrayOf(JtxContract.JtxRelatedto.ICALOBJECT_ID, JtxContract.JtxRelatedto.LINKEDICALOBJECT_ID, JtxContract.JtxRelatedto.RELTYPE), "${JtxContract.JtxRelatedto.ICALOBJECT_ID} = ? AND ${JtxContract.JtxRelatedto.LINKEDICALOBJECT_ID} = ? AND ${JtxContract.JtxRelatedto.RELTYPE} = ?", arrayOf(childId.toString(), parentId.toString(), JtxContract.JtxRelatedto.Reltype.PARENT.name), null).use {
                        cursor ->

                    // if the query does not bring any result, then we insert the opposite relationship
                    if (cursor?.moveToFirst() == false) {

                        //get the UID of the linked entry
                        client.query(JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), arrayOf(JtxContract.JtxICalObject.UID), "${JtxContract.JtxICalObject.ID} = ?", arrayOf(parentId.toString()), null).use {
                                foundIcalObjectCursor ->

                            if(foundIcalObjectCursor?.moveToFirst() == true) {
                                val parentUID = foundIcalObjectCursor.getString(0)
                                val cv = ContentValues().apply {
                                    put(JtxContract.JtxRelatedto.ICALOBJECT_ID, childId)
                                    put(JtxContract.JtxRelatedto.LINKEDICALOBJECT_ID, parentId)
                                    put(JtxContract.JtxRelatedto.RELTYPE, JtxContract.JtxRelatedto.Reltype.PARENT.name)
                                    put(JtxContract.JtxRelatedto.TEXT, parentUID)
                                }
                                client.insert(JtxContract.JtxRelatedto.CONTENT_URI.asSyncAdapter(account), cv)
                            }
                        }
                    }
                }
            }
        }
    }

    /**
     * @return a string with all JtxICalObjects within the collection as iCalendar
     */
    fun getICSForCollection(): String {
        client.query(JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), null, "${JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID} = ? AND ${JtxContract.JtxICalObject.DELETED} = ?", arrayOf(id.toString(), "0"), null).use { cursor ->
        client.query(
            JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account),
            null,
            "${JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID} = ? AND ${JtxContract.JtxICalObject.DELETED} = ? AND ${JtxContract.JtxICalObject.RECURID} IS NULL",
            arrayOf(id.toString(), "0"),
            null
        ).use { cursor ->
            Ical4Android.log.fine("getICSForCollection: found ${cursor?.count} records in ${account.name}")

            val ical = Calendar()
+96 −54
Original line number Diff line number Diff line
@@ -24,7 +24,10 @@ import net.fortuna.ical4j.model.component.VJournal
import net.fortuna.ical4j.model.component.VToDo
import net.fortuna.ical4j.model.parameter.*
import net.fortuna.ical4j.model.property.*
import java.io.*
import java.io.FileNotFoundException
import java.io.IOException
import java.io.OutputStream
import java.io.Reader
import java.net.URI
import java.net.URISyntaxException
import java.time.format.DateTimeParseException
@@ -100,6 +103,8 @@ open class JtxICalObject(
    var alarms: MutableList<Alarm> = mutableListOf()
    var unknown: MutableList<Unknown> = mutableListOf()

    private var recurInstances: MutableList<JtxICalObject> = mutableListOf()




@@ -628,6 +633,17 @@ open class JtxICalObject(
            calComponent.components.add(vAlarm)
        }


        recurInstances.forEach { recurInstance ->
            val recurCalComponent = when (recurInstance.component) {
                JtxContract.JtxICalObject.Component.VTODO.name -> VToDo(true /* generates DTSTAMP */)
                JtxContract.JtxICalObject.Component.VJOURNAL.name -> VJournal(true /* generates DTSTAMP */)
                else -> return null
            }
            ical.components += recurCalComponent
            recurInstance.addProperties(recurCalComponent.properties)
        }

        ICalendar.softValidate(ical)
        return ical
    }
@@ -870,7 +886,10 @@ open class JtxICalObject(
            props += RRule(rrule)
        }
        recurid?.let { recurid ->
            props += RecurrenceId(recurid)
            props += if(dtstartTimezone == TZ_ALLDAY)
                RecurrenceId(Date(recurid))
            else
                RecurrenceId(DateTime(recurid))
        }

        rdate?.let { rdateString ->
@@ -1568,61 +1587,61 @@ duration?.let(props::add)
                }
                unknown.add(unknwn)
            }

        getRecurInstancesContentValues().forEach { recurInstanceValues ->
            recurInstances.add(
                JtxICalObject(collection).apply { populateFromContentValues(recurInstanceValues) }
            )

        }
    }

    /**
     * Puts the current JtxICalObjects attributes into Content Values
     * @return The JtxICalObject attributes as [ContentValues] (exluding list properties)
     */
    private fun toContentValues(): ContentValues {

        val values = ContentValues()
        values.put(JtxContract.JtxICalObject.ID, id)
        summary.let { values.put(JtxContract.JtxICalObject.SUMMARY, it)  }
        description.let { values.put(JtxContract.JtxICalObject.DESCRIPTION, it) }
        values.put(JtxContract.JtxICalObject.COMPONENT, component)
        status.let { values.put(JtxContract.JtxICalObject.STATUS, it) }
        classification.let { values.put(JtxContract.JtxICalObject.CLASSIFICATION, it) }
        priority.let { values.put(JtxContract.JtxICalObject.PRIORITY, it) }
        values.put(JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID, collectionId)
        values.put(JtxContract.JtxICalObject.UID, uid)
        values.put(JtxContract.JtxICalObject.COLOR, color)
        values.put(JtxContract.JtxICalObject.URL, url)
        geoLat.let { values.put(JtxContract.JtxICalObject.GEO_LAT, it) }
        geoLong.let { values.put(JtxContract.JtxICalObject.GEO_LONG, it) }
        location.let { values.put(JtxContract.JtxICalObject.LOCATION, it) }
        locationAltrep.let { values.put(JtxContract.JtxICalObject.LOCATION_ALTREP, it) }
        percent.let { values.put(JtxContract.JtxICalObject.PERCENT, it) }
        values.put(JtxContract.JtxICalObject.DTSTAMP, dtstamp)
        dtstart.let { values.put(JtxContract.JtxICalObject.DTSTART, it) }
        dtstartTimezone.let { values.put(JtxContract.JtxICalObject.DTSTART_TIMEZONE, it) }
        dtend.let { values.put(JtxContract.JtxICalObject.DTEND, it) }
        dtendTimezone.let { values.put(JtxContract.JtxICalObject.DTEND_TIMEZONE, it) }
        completed.let { values.put(JtxContract.JtxICalObject.COMPLETED, it) }
        completedTimezone.let { values.put(JtxContract.JtxICalObject.COMPLETED_TIMEZONE, it) }
        due.let { values.put(JtxContract.JtxICalObject.DUE, it) }
        dueTimezone.let { values.put(JtxContract.JtxICalObject.DUE_TIMEZONE, it) }
        duration.let { values.put(JtxContract.JtxICalObject.DURATION, it) }

        created.let { values.put(JtxContract.JtxICalObject.CREATED, it) }
        lastModified.let { values.put(JtxContract.JtxICalObject.LAST_MODIFIED, it) }
        sequence.let { values.put(JtxContract.JtxICalObject.SEQUENCE, it) }

        rrule.let { values.put(JtxContract.JtxICalObject.RRULE, it) }
        rdate.let { values.put(JtxContract.JtxICalObject.RDATE, it) }
        exdate.let { values.put(JtxContract.JtxICalObject.EXDATE, it) }
        recurid.let { values.put(JtxContract.JtxICalObject.RECURID, it) }

        fileName.let { values.put(JtxContract.JtxICalObject.FILENAME, it) }
        eTag.let { values.put(JtxContract.JtxICalObject.ETAG, it) }
        scheduleTag.let { values.put(JtxContract.JtxICalObject.SCHEDULETAG, it) }
        values.put(JtxContract.JtxICalObject.FLAGS, flags)
        values.put(JtxContract.JtxICalObject.DIRTY, dirty)

        return values
    private fun toContentValues() = ContentValues().apply {
            put(JtxContract.JtxICalObject.ID, id)
            put(JtxContract.JtxICalObject.SUMMARY, summary)
            put(JtxContract.JtxICalObject.DESCRIPTION, description)
            put(JtxContract.JtxICalObject.COMPONENT, component)
            put(JtxContract.JtxICalObject.STATUS, status)
            put(JtxContract.JtxICalObject.CLASSIFICATION, classification)
            put(JtxContract.JtxICalObject.PRIORITY, priority)
            put(JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID, collectionId)
            put(JtxContract.JtxICalObject.UID, uid)
            put(JtxContract.JtxICalObject.COLOR, color)
            put(JtxContract.JtxICalObject.URL, url)
            put(JtxContract.JtxICalObject.GEO_LAT, geoLat)
            put(JtxContract.JtxICalObject.GEO_LONG, geoLong)
            put(JtxContract.JtxICalObject.LOCATION, location)
            put(JtxContract.JtxICalObject.LOCATION_ALTREP, locationAltrep)
            put(JtxContract.JtxICalObject.PERCENT, percent)
            put(JtxContract.JtxICalObject.DTSTAMP, dtstamp)
            put(JtxContract.JtxICalObject.DTSTART, dtstart)
            put(JtxContract.JtxICalObject.DTSTART_TIMEZONE, dtstartTimezone)
            put(JtxContract.JtxICalObject.DTEND, dtend)
            put(JtxContract.JtxICalObject.DTEND_TIMEZONE, dtendTimezone)
            put(JtxContract.JtxICalObject.COMPLETED, completed)
            put(JtxContract.JtxICalObject.COMPLETED_TIMEZONE, completedTimezone)
            put(JtxContract.JtxICalObject.DUE, due)
            put(JtxContract.JtxICalObject.DUE_TIMEZONE, dueTimezone)
            put(JtxContract.JtxICalObject.DURATION, duration)
            put(JtxContract.JtxICalObject.CREATED, created)
            put(JtxContract.JtxICalObject.LAST_MODIFIED, lastModified)
            put(JtxContract.JtxICalObject.SEQUENCE, sequence)
            put(JtxContract.JtxICalObject.RRULE, rrule)
            put(JtxContract.JtxICalObject.RDATE, rdate)
            put(JtxContract.JtxICalObject.EXDATE, exdate)
            put(JtxContract.JtxICalObject.RECURID, recurid)

            put(JtxContract.JtxICalObject.FILENAME, fileName)
            put(JtxContract.JtxICalObject.ETAG, eTag)
            put(JtxContract.JtxICalObject.SCHEDULETAG, scheduleTag)
            put(JtxContract.JtxICalObject.FLAGS, flags)
            put(JtxContract.JtxICalObject.DIRTY, dirty)
        }


    /**
     * @return The categories of the given JtxICalObject as a list of ContentValues
     */
@@ -1814,4 +1833,27 @@ duration?.let(props::add)
        }
        return unknownValues
    }

    /**
     * @return The unknown properties of the given JtxICalObject as a list of ContentValues
     */
    private fun getRecurInstancesContentValues(): List<ContentValues> {

        val instancesUrl = JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(collection.account)
        val instancesValues: MutableList<ContentValues> = mutableListOf()
        if(rrule?.isNotEmpty() == true) {
            collection.client.query(
                instancesUrl,
                null,
                "${JtxContract.JtxICalObject.UID} = ? AND ${JtxContract.JtxICalObject.RECURID} IS NOT NULL AND ${JtxContract.JtxICalObject.SEQUENCE} > 0",
                arrayOf(uid),
                null
            )?.use { cursor ->
                while (cursor.moveToNext()) {
                    instancesValues.add(cursor.toValues())
                }
            }
        }
        return instancesValues
    }
}
+1 −1
Original line number Diff line number Diff line
@@ -28,7 +28,7 @@ class TaskProvider private constructor(
            private val readPermission: String,
            private val writePermission: String
    ) {
        JtxBoard("at.techbee.jtx.provider", "at.techbee.jtx", 101010006, "1.01.01", PERMISSION_JTX_READ, PERMISSION_JTX_WRITE),
        JtxBoard("at.techbee.jtx.provider", "at.techbee.jtx", 203000001, "2.03.00", PERMISSION_JTX_READ, PERMISSION_JTX_WRITE),
        TasksOrg("org.tasks.opentasks", "org.tasks", 100000, "10.0", PERMISSION_TASKS_ORG_READ, PERMISSION_TASKS_ORG_WRITE),
        OpenTasks("org.dmfs.tasks", "org.dmfs.tasks", 103, "1.1.8.2", PERMISSION_OPENTASKS_READ, PERMISSION_OPENTASKS_WRITE);