From feb63df6ee22a7bfd16fe2554f5bc29260a0e52b Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Thu, 21 May 2026 10:20:06 +0200 Subject: [PATCH 1/3] Add JtxRecurringCollection for managing recurring jtx objects and exceptions + tests --- .../storage/jtx/JtxRecurringCollectionTest.kt | 497 ++++++++++++++++++ .../storage/jtx/JtxRecurringCollection.kt | 406 ++++++++++++++ .../bitfire/synctools/test/AssertHelpers.kt | 21 + 3 files changed, 924 insertions(+) create mode 100644 lib/src/androidTest/kotlin/at/bitfire/synctools/storage/jtx/JtxRecurringCollectionTest.kt create mode 100644 lib/src/main/kotlin/at/bitfire/synctools/storage/jtx/JtxRecurringCollection.kt diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/jtx/JtxRecurringCollectionTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/jtx/JtxRecurringCollectionTest.kt new file mode 100644 index 000000000..343dfa17a --- /dev/null +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/jtx/JtxRecurringCollectionTest.kt @@ -0,0 +1,497 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.storage.jtx + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentValues +import android.content.Entity +import androidx.core.content.contentValuesOf +import androidx.test.platform.app.InstrumentationRegistry +import at.bitfire.ical4android.TaskProvider +import at.bitfire.synctools.test.GrantPermissionOrSkipRule +import at.bitfire.synctools.test.assertJtxObjectAndExceptionsEqual +import at.bitfire.synctools.test.withJtxId +import at.bitfire.synctools.verifyCompat +import at.techbee.jtx.JtxContract +import at.techbee.jtx.JtxContract.JtxICalObject.Component +import io.mockk.junit4.MockKRule +import io.mockk.spyk +import org.junit.After +import org.junit.AfterClass +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.BeforeClass +import org.junit.ClassRule +import org.junit.Rule +import org.junit.Test +import java.util.UUID + +class JtxRecurringCollectionTest { + + companion object { + + @JvmField + @ClassRule + val permissionRule = GrantPermissionOrSkipRule(TaskProvider.PERMISSIONS_JTX.toSet()) + + private val testAccount = Account( + JtxRecurringCollectionTest::class.java.name, + JtxContract.JtxCollection.TEST_ACCOUNT_TYPE + ) + + private lateinit var client: ContentProviderClient + private lateinit var provider: JtxCollectionProvider + private lateinit var collection: JtxCollection + private lateinit var recurringCollection: JtxRecurringCollection + + @BeforeClass + @JvmStatic + fun setUpClass() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + client = context.contentResolver.acquireContentProviderClient(JtxContract.AUTHORITY)!! + provider = JtxCollectionProvider(testAccount, client) + collection = provider.createAndGetCollection(contentValuesOf( + JtxContract.JtxCollection.URL to "https://example.com/recurring-test", + JtxContract.JtxCollection.DISPLAYNAME to "Recurring Test Collection", + JtxContract.JtxCollection.SUPPORTSVTODO to true, + JtxContract.JtxCollection.SUPPORTSVJOURNAL to true + )) + recurringCollection = spyk(JtxRecurringCollection(collection)) + } + + @AfterClass + @JvmStatic + fun tearDownClass() { + collection.delete() + client.close() + } + + } + + @get:Rule + val mockkRule = MockKRule(this) + + @After + fun cleanUp() { + collection.deleteAllJtxObjects() + } + + + // test CRUD + + @Test + fun testAddJtxObjectAndExceptions_and_GetById() { + val (mainId, obj) = insertRecurring() + val addedWithId = obj.withJtxId(mainId) + + // verify that cleanUp was called + verifyCompat(exactly = 1) { + recurringCollection.cleanUp(obj) + } + + // verify stored data + val result = recurringCollection.getById(mainId) + assertJtxObjectAndExceptionsEqual(addedWithId, result!!, onlyFieldsInExpected = true) + } + + @Test + fun testFindJtxObjectAndExceptions() { + val uid = "testFindJtxObjectAndExceptions" + val (mainId, obj) = insertRecurring(uid = uid) + val addedWithId = obj.withJtxId(mainId) + + val result = recurringCollection.findJtxObjectAndExceptions( + "${JtxContract.JtxICalObject.UID}=?", + arrayOf(uid) + ) + assertJtxObjectAndExceptionsEqual(addedWithId, result!!, onlyFieldsInExpected = true) + } + + @Test + fun testFindJtxObjectAndExceptions_NotFound() { + assertNull( + recurringCollection.findJtxObjectAndExceptions( + "${JtxContract.JtxICalObject.UID}=?", + arrayOf("does-not-exist") + ) + ) + } + + @Test + fun testGetById_NotFound() { + recurringCollection.deleteJtxObjectAndExceptions(Long.MAX_VALUE) + assertNull(recurringCollection.getById(Long.MAX_VALUE)) + } + + @Test + fun testIterateJtxObjectAndExceptions() { + val uid1 = "testIterateJtxObjectAndExceptions1" + val uid2 = "testIterateJtxObjectAndExceptions2" + val (id1, obj1) = insertRecurring(uid = uid1) + val (id2, obj2) = insertRecurring(uid = uid2) + + val result = mutableListOf() + recurringCollection.iterateJtxObjectAndExceptions( + "${JtxContract.JtxICalObject.UID} IN (?, ?)", + arrayOf(uid1, uid2) + ) { result += it } + + val orderedResult = result.sortedBy { it.main.entityValues.getAsLong(JtxContract.JtxICalObject.ID) } + assertEquals(2, orderedResult.size) + assertJtxObjectAndExceptionsEqual(obj1.withJtxId(id1), orderedResult[0], onlyFieldsInExpected = true) + assertJtxObjectAndExceptionsEqual(obj2.withJtxId(id2), orderedResult[1], onlyFieldsInExpected = true) + } + + @Test + fun testIterateJtxObjectAndExceptions_NotFound() { + recurringCollection.iterateJtxObjectAndExceptions( + "${JtxContract.JtxICalObject.UID}=?", + arrayOf("does-not-exist") + ) { + fail("body must not be called, when does not exist") + } + } + + @Test + fun testUpdateJtxObjectAndExceptions() { + val now = 1754233504000L // Sun Aug 03 2025 15:05:04 GMT+0000 + val uid = "testUpdateJtxObjectAndExceptions" + val initialMain = Entity(contentValuesOf( + JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID to collection.id, + JtxContract.JtxICalObject.COMPONENT to Component.VTODO.name, + JtxContract.JtxICalObject.UID to uid, + JtxContract.JtxICalObject.SUMMARY to "Initial Main", + JtxContract.JtxICalObject.DTSTART to now, + JtxContract.JtxICalObject.DTSTART_TIMEZONE to "UTC", + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=3" + )) + val initialException = Entity(contentValuesOf( + JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID to collection.id, + JtxContract.JtxICalObject.COMPONENT to Component.VTODO.name, + JtxContract.JtxICalObject.UID to uid, + JtxContract.JtxICalObject.SUMMARY to "Initial Exception", + JtxContract.JtxICalObject.DTSTART to now + 86400000, + JtxContract.JtxICalObject.DTSTART_TIMEZONE to "UTC", + JtxContract.JtxICalObject.RECURID to "20250804T150504Z", + JtxContract.JtxICalObject.RECURID_TIMEZONE to "UTC" + )) + val addedId = recurringCollection.addJtxObjectAndExceptions( + JtxObjectAndExceptions(main = initialMain, exceptions = listOf(initialException)) + ) + + val updatedMain = Entity(contentValuesOf( + JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID to collection.id, + JtxContract.JtxICalObject.COMPONENT to Component.VTODO.name, + JtxContract.JtxICalObject.UID to uid, + JtxContract.JtxICalObject.SUMMARY to "Updated Main", + JtxContract.JtxICalObject.DTSTART to now, + JtxContract.JtxICalObject.DTSTART_TIMEZONE to "UTC", + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=3" + )) + val updatedException = Entity(contentValuesOf( + JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID to collection.id, + JtxContract.JtxICalObject.COMPONENT to Component.VTODO.name, + JtxContract.JtxICalObject.UID to uid, + JtxContract.JtxICalObject.SUMMARY to "Updated Exception", + JtxContract.JtxICalObject.DTSTART to now + 86400000, + JtxContract.JtxICalObject.DTSTART_TIMEZONE to "UTC", + JtxContract.JtxICalObject.RECURID to "20250804T150504Z", + JtxContract.JtxICalObject.RECURID_TIMEZONE to "UTC" + )) + val updatedObj = JtxObjectAndExceptions(main = updatedMain, exceptions = listOf(updatedException)) + + val updatedId = recurringCollection.updateJtxObjectAndExceptions(addedId, updatedObj) + assertEquals(addedId, updatedId) + + // verify that cleanUp was called (once for add, once for update) + verifyCompat(exactly = 1) { + recurringCollection.cleanUp(updatedObj) + } + + val result = recurringCollection.getById(addedId) + assertJtxObjectAndExceptionsEqual(updatedObj.withJtxId(addedId), result!!, onlyFieldsInExpected = true) + } + + @Test + fun testDeleteJtxObjectAndExceptions() { + val now = 1754233504000L + val uid = "testDeleteJtxObjectAndExceptions" + val mainId = recurringCollection.addJtxObjectAndExceptions(JtxObjectAndExceptions( + main = Entity(contentValuesOf( + JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID to collection.id, + JtxContract.JtxICalObject.COMPONENT to Component.VTODO.name, + JtxContract.JtxICalObject.UID to uid, + JtxContract.JtxICalObject.SUMMARY to "Main", + JtxContract.JtxICalObject.DTSTART to now, + JtxContract.JtxICalObject.DTSTART_TIMEZONE to "UTC", + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=3" + )), + exceptions = listOf( + Entity(contentValuesOf( + JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID to collection.id, + JtxContract.JtxICalObject.COMPONENT to Component.VTODO.name, + JtxContract.JtxICalObject.UID to uid, + JtxContract.JtxICalObject.SUMMARY to "Exception", + JtxContract.JtxICalObject.DTSTART to now + 86400000, + JtxContract.JtxICalObject.DTSTART_TIMEZONE to "UTC", + JtxContract.JtxICalObject.RECURID to "20250804T150504Z", + JtxContract.JtxICalObject.RECURID_TIMEZONE to "UTC" + )) + ) + )) + + recurringCollection.deleteJtxObjectAndExceptions(mainId) + + assertNull(recurringCollection.getById(mainId)) + // verify exceptions are also gone + assertEquals(0, collection.countJtxObjects( + "${JtxContract.JtxICalObject.UID}=?", arrayOf(uid) + )) + } + + + // test validation / clean-up logic + + @Test + fun testCleanUp_Recurring_Exceptions_NoUid() { + val cleaned = recurringCollection.cleanUp(JtxObjectAndExceptions( + main = Entity(contentValuesOf( + JtxContract.JtxICalObject.SUMMARY to "Recurring Main", + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY" + // no UID + )), + exceptions = listOf( + Entity(contentValuesOf(JtxContract.JtxICalObject.SUMMARY to "Exception")) + ) + )) + + // exceptions must be dropped because UID is not set + assertTrue(cleaned.exceptions.isEmpty()) + } + + @Test + fun testCleanUp_Recurring_Exceptions_WithUid() { + val uid = "testCleanUp-uid" + val original = JtxObjectAndExceptions( + main = Entity(contentValuesOf( + JtxContract.JtxICalObject.UID to uid, + JtxContract.JtxICalObject.SUMMARY to "Recurring Main", + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY" + )), + exceptions = listOf( + Entity(contentValuesOf( + JtxContract.JtxICalObject.UID to uid, + JtxContract.JtxICalObject.SUMMARY to "Exception", + JtxContract.JtxICalObject.RECURID to "20250804T150504Z" + )) + ) + ) + val cleaned = recurringCollection.cleanUp(original) + + // exceptions must be retained (recurring + UID present) + assertEquals(1, cleaned.exceptions.size) + } + + @Test + fun testCleanUp_NotRecurring_Exceptions() { + val cleaned = recurringCollection.cleanUp(JtxObjectAndExceptions( + main = Entity(contentValuesOf( + JtxContract.JtxICalObject.UID to "some-uid", + JtxContract.JtxICalObject.SUMMARY to "Non-Recurring Main" + // no RRULE or RDATE + )), + exceptions = listOf( + Entity(contentValuesOf(JtxContract.JtxICalObject.SUMMARY to "Exception")) + ) + )) + + // exceptions must be dropped because main is not recurring + assertTrue(cleaned.exceptions.isEmpty()) + } + + @Test + fun testCleanMainObject_RemovesRecurIdFields() { + val result = recurringCollection.cleanMainObject(Entity(contentValuesOf( + JtxContract.JtxICalObject.SUMMARY to "Main", + JtxContract.JtxICalObject.RECURID to "20250804T150504Z", + JtxContract.JtxICalObject.RECURID_TIMEZONE to "UTC" + ))) + + // RECURID and RECURID_TIMEZONE must be removed; only SUMMARY remains + assertEquals(1, result.entityValues.size()) + assertEquals("Main", result.entityValues.getAsString(JtxContract.JtxICalObject.SUMMARY)) + } + + @Test + fun testCleanException_RemovesRecurrenceFields_SetsUid() { + val result = recurringCollection.cleanException(Entity(contentValuesOf( + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY", + JtxContract.JtxICalObject.RDATE to "20250804T150504Z", + JtxContract.JtxICalObject.EXDATE to "20250805T150504Z" + )), "target-uid") + + // all recurrence fields removed; UID set to the given value + assertEquals(1, result.entityValues.size()) + assertEquals("target-uid", result.entityValues.getAsString(JtxContract.JtxICalObject.UID)) + } + + + // test processing dirty/deleted exceptions + + @Test + fun testProcessDeletedExceptions() { + val now = System.currentTimeMillis() + val uid = "testProcessDeletedExceptions" + val mainValues = contentValuesOf( + JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID to collection.id, + JtxContract.JtxICalObject.COMPONENT to Component.VTODO.name, + JtxContract.JtxICalObject.UID to uid, + JtxContract.JtxICalObject.SUMMARY to "Main", + JtxContract.JtxICalObject.DTSTART to now, + JtxContract.JtxICalObject.DTSTART_TIMEZONE to "UTC", + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5", + JtxContract.JtxICalObject.DIRTY to 0, + JtxContract.JtxICalObject.DELETED to 0, + JtxContract.JtxICalObject.SEQUENCE to 15 + ) + val exNotDeleted = Entity(contentValuesOf( + JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID to collection.id, + JtxContract.JtxICalObject.COMPONENT to Component.VTODO.name, + JtxContract.JtxICalObject.UID to uid, + JtxContract.JtxICalObject.SUMMARY to "Not deleted exception", + JtxContract.JtxICalObject.DTSTART to now + 86400000, + JtxContract.JtxICalObject.DTSTART_TIMEZONE to "UTC", + JtxContract.JtxICalObject.RECURID to "20250804T150504Z", + JtxContract.JtxICalObject.RECURID_TIMEZONE to "UTC", + JtxContract.JtxICalObject.DIRTY to 0, + JtxContract.JtxICalObject.DELETED to 0 + )) + val mainId = recurringCollection.addJtxObjectAndExceptions( + JtxObjectAndExceptions( + main = Entity(mainValues), + exceptions = listOf( + exNotDeleted, + Entity(contentValuesOf( + JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID to collection.id, + JtxContract.JtxICalObject.COMPONENT to Component.VTODO.name, + JtxContract.JtxICalObject.UID to uid, + JtxContract.JtxICalObject.SUMMARY to "Deleted exception", + JtxContract.JtxICalObject.DTSTART to now + 2 * 86400000, + JtxContract.JtxICalObject.DTSTART_TIMEZONE to "UTC", + JtxContract.JtxICalObject.RECURID to "20250805T150504Z", + JtxContract.JtxICalObject.RECURID_TIMEZONE to "UTC", + JtxContract.JtxICalObject.DIRTY to 1, + JtxContract.JtxICalObject.DELETED to 1 + )) + ) + ) + ) + + // should update main object and purge the deleted exception + recurringCollection.processDeletedExceptions() + + val result = recurringCollection.getById(mainId)!! + assertJtxObjectAndExceptionsEqual( + JtxObjectAndExceptions( + main = Entity(ContentValues(mainValues).apply { + put(JtxContract.JtxICalObject.DIRTY, 1) + put(JtxContract.JtxICalObject.SEQUENCE, 16) + }), + exceptions = listOf(exNotDeleted) + ), result, onlyFieldsInExpected = true + ) + } + + @Test + fun testProcessDirtyExceptions() { + val now = System.currentTimeMillis() + val uid = "testProcessDirtyExceptions" + val mainValues = contentValuesOf( + JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID to collection.id, + JtxContract.JtxICalObject.COMPONENT to Component.VTODO.name, + JtxContract.JtxICalObject.UID to uid, + JtxContract.JtxICalObject.SUMMARY to "Main", + JtxContract.JtxICalObject.DTSTART to now, + JtxContract.JtxICalObject.DTSTART_TIMEZONE to "UTC", + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5", + JtxContract.JtxICalObject.DIRTY to 0, + JtxContract.JtxICalObject.DELETED to 0, + JtxContract.JtxICalObject.SEQUENCE to 15 + ) + val exDirtyValues = contentValuesOf( + JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID to collection.id, + JtxContract.JtxICalObject.COMPONENT to Component.VTODO.name, + JtxContract.JtxICalObject.UID to uid, + JtxContract.JtxICalObject.SUMMARY to "Dirty exception", + JtxContract.JtxICalObject.DTSTART to now + 86400000, + JtxContract.JtxICalObject.DTSTART_TIMEZONE to "UTC", + JtxContract.JtxICalObject.RECURID to "20250804T150504Z", + JtxContract.JtxICalObject.RECURID_TIMEZONE to "UTC", + JtxContract.JtxICalObject.DIRTY to 1, + JtxContract.JtxICalObject.DELETED to 0, + JtxContract.JtxICalObject.SEQUENCE to null + ) + val mainId = recurringCollection.addJtxObjectAndExceptions( + JtxObjectAndExceptions( + main = Entity(mainValues), + exceptions = listOf(Entity(exDirtyValues)) + ) + ) + + // should mark main as dirty and increase exception SEQUENCE + recurringCollection.processDirtyExceptions() + + val result = recurringCollection.getById(mainId)!! + assertJtxObjectAndExceptionsEqual( + JtxObjectAndExceptions( + main = Entity(ContentValues(mainValues).apply { + put(JtxContract.JtxICalObject.DIRTY, 1) + }), + exceptions = listOf(Entity(ContentValues(exDirtyValues).apply { + put(JtxContract.JtxICalObject.DIRTY, 0) + put(JtxContract.JtxICalObject.SEQUENCE, 2) + })) + ), result, onlyFieldsInExpected = true + ) + } + + + // helpers + + private fun insertRecurring(uid: String = UUID.randomUUID().toString()): Pair { + val now = 1754233504000L // Sun Aug 03 2025 15:05:04 GMT+0000 + val obj = JtxObjectAndExceptions( + main = Entity(contentValuesOf( + JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID to collection.id, + JtxContract.JtxICalObject.COMPONENT to Component.VTODO.name, + JtxContract.JtxICalObject.UID to uid, + JtxContract.JtxICalObject.SUMMARY to "Main Task", + JtxContract.JtxICalObject.DTSTART to now, + JtxContract.JtxICalObject.DTSTART_TIMEZONE to "UTC", + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=3" + )), + exceptions = listOf( + Entity(contentValuesOf( + JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID to collection.id, + JtxContract.JtxICalObject.COMPONENT to Component.VTODO.name, + JtxContract.JtxICalObject.UID to uid, + JtxContract.JtxICalObject.SUMMARY to "Exception Task", + JtxContract.JtxICalObject.DTSTART to now + 86400000, + JtxContract.JtxICalObject.DTSTART_TIMEZONE to "UTC", + JtxContract.JtxICalObject.RECURID to "20250804T150504Z", + JtxContract.JtxICalObject.RECURID_TIMEZONE to "UTC" + )) + ) + ) + val id = recurringCollection.addJtxObjectAndExceptions(obj) + return id to obj + } + +} diff --git a/lib/src/main/kotlin/at/bitfire/synctools/storage/jtx/JtxRecurringCollection.kt b/lib/src/main/kotlin/at/bitfire/synctools/storage/jtx/JtxRecurringCollection.kt new file mode 100644 index 000000000..6ea4ed096 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/storage/jtx/JtxRecurringCollection.kt @@ -0,0 +1,406 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.storage.jtx + +import android.content.ContentUris +import android.content.ContentValues +import android.content.Entity +import android.os.RemoteException +import androidx.annotation.VisibleForTesting +import androidx.core.content.contentValuesOf +import at.bitfire.synctools.storage.JtxBatchOperation +import at.bitfire.synctools.storage.LocalStorageException +import at.bitfire.synctools.storage.containsNotNull +import at.techbee.jtx.JtxContract +import java.util.logging.Level +import java.util.logging.Logger + +/** + * Adds support for [JtxObjectAndExceptions] data objects to [JtxCollection]. + * + * In the jtx Board content provider, recurring exceptions are linked to their main object + * exclusively by sharing the same [JtxContract.JtxICalObject.UID]. Exceptions are identified by + * a non-null [JtxContract.JtxICalObject.RECURID]. + * + * [JtxContract.JtxICalObject.RECUR_ORIGINALICALOBJECTID] is intentionally never used here. + */ +class JtxRecurringCollection( + val collection: JtxCollection +) { + + val logger: Logger + get() = Logger.getLogger(javaClass.name) + + /** + * Inserts a jtx object and all its exceptions. Input data is first cleaned up using [cleanUp]. + * + * @param objectAndExceptions object and exceptions to insert + * + * @return ID of the resulting main jtx object + */ + fun addJtxObjectAndExceptions(objectAndExceptions: JtxObjectAndExceptions): Long { + try { + // validate / clean up input + val cleaned = cleanUp(objectAndExceptions) + + // add main jtxObject + val batch = JtxBatchOperation(collection.client) + collection.addJtxObject(cleaned.main, batch) + + // add exceptions + for (exception in cleaned.exceptions) + collection.addJtxObject(exception, batch) + + batch.commit() + + // main object was created as first row (index 0), return its insert result (= ID) + val uri = batch.getResult(0)?.uri ?: throw LocalStorageException("Content provider returned null on insert") + return ContentUris.parseId(uri) + } catch (e: RemoteException) { + throw LocalStorageException("Couldn't insert jtx object/exceptions", e) + } + } + + /** + * Find first main jtx object (not an exception, i.e. [JtxContract.JtxICalObject.RECURID] IS NULL) + * that matches the given query, together with all its exceptions. + * + * @param where selection (applied to main objects only; [JtxContract.JtxICalObject.RECURID] IS NULL is added automatically) + * @param whereArgs arguments for selection + */ + fun findJtxObjectAndExceptions(where: String?, whereArgs: Array?): JtxObjectAndExceptions? { + val mainWhere = mainJtxObjectOnlyWhere(where) + + // attach exceptions + val main = collection.findJtxObject(mainWhere, whereArgs) ?: return null + val uid = main.entityValues.getAsString(JtxContract.JtxICalObject.UID) + return JtxObjectAndExceptions( + main = main, + exceptions = if (uid != null) findExceptionsByUid(uid) else emptyList() + ) + } + + /** + * Retrieves a jtx object and its exceptions from the content provider. + * Exceptions are found by matching [JtxContract.JtxICalObject.UID] of the main object. + * + * @param mainId [JtxContract.JtxICalObject.ID] of the main jtx object + * + * @return jtx object and exceptions, or `null` if not found + */ + fun getById(mainId: Long): JtxObjectAndExceptions? { + val main = collection.getJtxObject(mainId) ?: return null + val uid = main.entityValues.getAsString(JtxContract.JtxICalObject.UID) + return JtxObjectAndExceptions( + main = main, + exceptions = if (uid != null) findExceptionsByUid(uid) else emptyList() + ) + } + + /** + * Iterates through main jtx objects together with their exceptions from the content provider. + * + * @param where selection (applied to main objects only; [JtxContract.JtxICalObject.RECURID] IS NULL is added automatically) + * @param whereArgs arguments for selection + * @param body callback that is called for each main object (with exceptions attached) + */ + fun iterateJtxObjectAndExceptions(where: String?, whereArgs: Array?, body: (JtxObjectAndExceptions) -> Unit) { + val mainWhere = mainJtxObjectOnlyWhere(where) + // iterate through main events and attach exceptions + collection.iterateJtxObjects(mainWhere, whereArgs) { main -> + val uid = main.entityValues.getAsString(JtxContract.JtxICalObject.UID) + body(JtxObjectAndExceptions( + main = main, + exceptions = if (uid != null) findExceptionsByUid(uid) else emptyList() + )) + } + } + + /** + * Updates a jtx object and all its exceptions. Input data is first cleaned up using [cleanUp]. + * Old exceptions are deleted and replaced with the ones provided. + * + * @param id ID of the main jtx object row + * @param objectAndExceptions new jtx object (including exceptions) + * + * @return main jtx object ID of the updated row (always equal to [id]) + */ + fun updateJtxObjectAndExceptions(id: Long, objectAndExceptions: JtxObjectAndExceptions): Long { + try { + // validate / clean up input + val cleaned = cleanUp(objectAndExceptions) + + // get UID of the existing main object to find and delete its old exceptions (because + // they may be invalid for the updated event) + val existingUid = collection.getJtxObjectRow(id, arrayOf(JtxContract.JtxICalObject.UID)) + ?.getAsString(JtxContract.JtxICalObject.UID) + + val batch = JtxBatchOperation(collection.client) + + // Delete old exceptions individually by item URI. The jtx Board provider blocks + // bulk directory-level deletes of RECURID-bearing rows (adds "AND RECURID IS NULL"), + // so a bulk delete would silently be a no-op here. + // See: https://github.com/TechbeeAT/jtxBoard/blob/45a5f75693b2a50e55f2812bb5681016e50500d8/app/src/main/java/at/techbee/jtx/SyncContentProvider.kt#L197 + if (existingUid != null) + for (oldException in findExceptionsByUid(existingUid)) + collection.deleteJtxObject( + oldException.entityValues.getAsLong(JtxContract.JtxICalObject.ID), + batch + ) + + // update main object + collection.updateJtxObject(id, cleaned.main, batch) + + // add updated exceptions + for (exception in cleaned.exceptions) + collection.addJtxObject(exception, batch) + + batch.commit() + return id + } catch (e: RemoteException) { + throw LocalStorageException("Couldn't update jtx object/exceptions", e) + } + } + + /** + * Deletes a jtx object and all its exceptions. + * + * @param id ID of the main jtx object + */ + fun deleteJtxObjectAndExceptions(id: Long) { + try { + val batch = JtxBatchOperation(collection.client) + + // delete main object; the jtx Board provider's removeOrphans() will clean up + // associated exceptions and auto-generated instances when the main is removed + collection.deleteJtxObject(id, batch) + + batch.commit() + } catch (e: RemoteException) { + throw LocalStorageException("Couldn't delete jtx object $id", e) + } + } + + + // validation / cleanup logic + + /** + * Prepares a jtx object and exceptions so that it can be inserted into the jtx Board provider: + * + * - If the main object is not recurring (no RRULE or RDATE) or has no [JtxContract.JtxICalObject.UID], + * exceptions are dropped. + * - Cleans up the main object with [cleanMainObject]. + * - Cleans up exceptions with [cleanException]. + * + * @param original original object and exceptions + * + * @return object and exceptions that can actually be inserted + */ + @VisibleForTesting + internal fun cleanUp(original: JtxObjectAndExceptions): JtxObjectAndExceptions { + val main = cleanMainObject(original.main) + + val mainValues = main.entityValues + val uid = mainValues.getAsString(JtxContract.JtxICalObject.UID) + val recurring = mainValues.containsNotNull(JtxContract.JtxICalObject.RRULE) + || mainValues.containsNotNull(JtxContract.JtxICalObject.RDATE) + + if (uid == null || !recurring) { + // without a UID, exceptions cannot be associated to the main object in the jtx provider + // and without recurrence fields, exceptions are meaningless + if (original.exceptions.isNotEmpty()) + logger.log(Level.WARNING, "Dropping exceptions of jtx object because it is not recurring or UID is not set", main) + + return JtxObjectAndExceptions(main = main, exceptions = emptyList()) + } + + return JtxObjectAndExceptions( + main = main, + exceptions = original.exceptions.map { originalException -> + cleanException(originalException, uid) + } + ) + } + + /** + * Prepares a main jtx object for insertion by removing fields that a main must not have + * ([JtxContract.JtxICalObject.RECURID] and [JtxContract.JtxICalObject.RECURID_TIMEZONE]). + * + * @param original original jtx object entity + * + * @return cleaned entity that can actually be inserted as a main + */ + @VisibleForTesting + internal fun cleanMainObject(original: Entity): Entity { + // make a copy (don't modify original entity / values) + val values = ContentValues(original.entityValues) + + // remove values that a main jtx object shouldn't have + values.remove(JtxContract.JtxICalObject.RECURID) + values.remove(JtxContract.JtxICalObject.RECURID_TIMEZONE) + + val result = Entity(values) + for (subValue in original.subValues) + result.addSubValue(subValue.uri, subValue.values) + return result + } + + /** + * Prepares an exception for insertion into the jtx Board provider: + * + * - Removes recurrence rule fields that an exception must not have (`RRULE`, `RDATE`, `EXDATE`). + * EXRULE does not apply/unsupported. + * - Ensures [JtxContract.JtxICalObject.UID] matches the main's UID so that the jtx Board + * provider can associate the exception with its main. + * + * @param original original exception entity + * @param uid [JtxContract.JtxICalObject.UID] of the main object + * + * @return cleaned exception that can actually be inserted + */ + @VisibleForTesting + internal fun cleanException(original: Entity, uid: String): Entity { + // make a copy (don't modify original entity / values) + val values = ContentValues(original.entityValues) + + // remove fields that an exception must not have + values.remove(JtxContract.JtxICalObject.RRULE) + values.remove(JtxContract.JtxICalObject.RDATE) + values.remove(JtxContract.JtxICalObject.EXDATE) + + // ensure UID matches the main so the jtx provider can link them + values.put(JtxContract.JtxICalObject.UID, uid) + + val result = Entity(values) + for (subValue in original.subValues) + result.addSubValue(subValue.uri, subValue.values) + return result + } + + + // helpers for dirty/deleted exceptions + + /** + * Iterates through all exceptions in [collection] that are marked as deleted. + * For every found exception: + * + * - the [JtxContract.JtxICalObject.SEQUENCE] field of the main oject is increased by one, + * - the main object is marked as dirty (so that it will be synced), + * - and the exception is permanently removed (so that it won't appear during sync). + * + * The main is found by matching [JtxContract.JtxICalObject.UID] with + * [JtxContract.JtxICalObject.RECURID] IS NULL. + */ + fun processDeletedExceptions() { + val batch = JtxBatchOperation(collection.client) + + // iterate through deleted exceptions + collection.iterateJtxObjectRows( + arrayOf(JtxContract.JtxICalObject.ID, JtxContract.JtxICalObject.UID), + "${JtxContract.JtxICalObject.DELETED}=1 AND ${JtxContract.JtxICalObject.RECURID} IS NOT NULL", null + ) { values -> + val exceptionId = values.getAsLong(JtxContract.JtxICalObject.ID)!! + val uid = values.getAsString(JtxContract.JtxICalObject.UID) ?: return@iterateJtxObjectRows + logger.fine("Found deleted exception #$exceptionId, removing it and marking main jtx object (uid=$uid) as dirty") + + // find main object and its current SEQUENCE + val mainValues = collection.findJtxObjectRow( + arrayOf(JtxContract.JtxICalObject.ID, JtxContract.JtxICalObject.SEQUENCE), + "${JtxContract.JtxICalObject.UID}=? AND ${JtxContract.JtxICalObject.RECURID} IS NULL", + arrayOf(uid) + ) ?: return@iterateJtxObjectRows + + val mainId = mainValues.getAsLong(JtxContract.JtxICalObject.ID)!! + val mainSeq = mainValues.getAsInteger(JtxContract.JtxICalObject.SEQUENCE) ?: 0 + + // increase SEQUENCE and mark main as dirty + collection.updateJtxObjectRow(mainId, contentValuesOf( + JtxContract.JtxICalObject.SEQUENCE to mainSeq + 1, + JtxContract.JtxICalObject.DIRTY to 1 + ), batch) + + // permanently remove the deleted exception + collection.deleteJtxObject(exceptionId, batch) + } + + batch.commit() + } + + /** + * Iterates through all exceptions in [collection] that are marked as dirty (but not deleted). + * For every found exception: + * + * - the [JtxContract.JtxICalObject.SEQUENCE] field of the exception is increased by one, + * - the exception is marked as not dirty anymore, + * - but the main is marked as dirty (so that it will be synced). + * + * The main is found by matching [JtxContract.JtxICalObject.UID] with + * [JtxContract.JtxICalObject.RECURID] IS NULL. + */ + fun processDirtyExceptions() { + val batch = JtxBatchOperation(collection.client) + + collection.iterateJtxObjectRows( + arrayOf(JtxContract.JtxICalObject.ID, JtxContract.JtxICalObject.UID, JtxContract.JtxICalObject.SEQUENCE), + "${JtxContract.JtxICalObject.DIRTY}=1 AND ${JtxContract.JtxICalObject.DELETED}=0 AND ${JtxContract.JtxICalObject.RECURID} IS NOT NULL", null + ) { values -> + val exceptionId = values.getAsLong(JtxContract.JtxICalObject.ID)!! + val uid = values.getAsString(JtxContract.JtxICalObject.UID) ?: return@iterateJtxObjectRows + val exceptionSeq = values.getAsInteger(JtxContract.JtxICalObject.SEQUENCE) ?: 0 + logger.fine("Found dirty exception #$exceptionId, increasing SEQUENCE and marking main jtx object (uid=$uid) as dirty") + + // find main + val mainValues = collection.findJtxObjectRow( + arrayOf(JtxContract.JtxICalObject.ID), + "${JtxContract.JtxICalObject.UID}=? AND ${JtxContract.JtxICalObject.RECURID} IS NULL", + arrayOf(uid) + ) ?: return@iterateJtxObjectRows + + val mainId = mainValues.getAsLong(JtxContract.JtxICalObject.ID)!! + + // mark main as dirty + collection.updateJtxObjectRow(mainId, contentValuesOf( + JtxContract.JtxICalObject.DIRTY to 1 + ), batch) + + // increase exception SEQUENCE and clear DIRTY + collection.updateJtxObjectRow(exceptionId, contentValuesOf( + JtxContract.JtxICalObject.SEQUENCE to exceptionSeq + 1, + JtxContract.JtxICalObject.DIRTY to 0 + ), batch) + } + + batch.commit() + } + + + // private helpers + + /** + * Finds all exceptions for a given main object UID from this collection. + * Exceptions are identified by [JtxContract.JtxICalObject.RECURID] IS NOT NULL and + * [JtxContract.JtxICalObject.SEQUENCE] > 0. The jtx Board content provider auto-generates + * recurrence instances (SEQUENCE = 0) from the RRULE of recurring items; those are not iCal + * exceptions and must not be treated as such. The provider sets SEQUENCE to at least 1 for any + * sync-adapter-inserted exception. + */ + private fun findExceptionsByUid(uid: String): List = + collection.findJtxObjects( + "${JtxContract.JtxICalObject.UID}=? AND ${JtxContract.JtxICalObject.RECURID} IS NOT NULL AND ${JtxContract.JtxICalObject.SEQUENCE} > 0", + arrayOf(uid) + ) + + /** + * Adds [JtxContract.JtxICalObject.RECURID] IS NULL to [where] to restrict queries to main objects only. + */ + private fun mainJtxObjectOnlyWhere(where: String?): String = + if (where != null) + "($where) AND ${JtxContract.JtxICalObject.RECURID} IS NULL" + else + "${JtxContract.JtxICalObject.RECURID} IS NULL" + +} diff --git a/lib/src/main/kotlin/at/bitfire/synctools/test/AssertHelpers.kt b/lib/src/main/kotlin/at/bitfire/synctools/test/AssertHelpers.kt index 890c9260f..7b7a29258 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/test/AssertHelpers.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/test/AssertHelpers.kt @@ -10,6 +10,8 @@ import android.content.ContentValues import android.content.Entity import android.provider.CalendarContract.Events import at.bitfire.synctools.storage.calendar.EventAndExceptions +import at.bitfire.synctools.storage.jtx.JtxObjectAndExceptions +import at.techbee.jtx.JtxContract import org.junit.Assert.assertEquals fun assertContentValuesEqual(expected: ContentValues, actual: ContentValues, onlyFieldsInExpected: Boolean = false, message: String? = null) { @@ -53,6 +55,19 @@ fun assertEventAndExceptionsEqual(expected: EventAndExceptions, actual: EventAnd } } +fun assertJtxObjectAndExceptionsEqual(expected: JtxObjectAndExceptions, actual: JtxObjectAndExceptions, onlyFieldsInExpected: Boolean = false) { + assertEntitiesEqual(expected.main, actual.main, onlyFieldsInExpected) + + assertEquals(expected.exceptions.size, actual.exceptions.size) + for (expectedException in expected.exceptions) { + val expectedRecurId = expectedException.entityValues.getAsString(JtxContract.JtxICalObject.RECURID) + val actualException = actual.exceptions.first { + it.entityValues.getAsString(JtxContract.JtxICalObject.RECURID) == expectedRecurId + } + assertEntitiesEqual(expectedException, actualException, onlyFieldsInExpected) + } +} + fun Entity.withIntField(name: String, value: Long?) = Entity(ContentValues(this.entityValues)).also { newEntity -> newEntity.entityValues.put(name, value) @@ -68,4 +83,10 @@ fun EventAndExceptions.withId(mainEventId: Long) = exceptions = exceptions.map { exception -> exception.withIntField(Events.ORIGINAL_ID, mainEventId) } + ) + +fun JtxObjectAndExceptions.withJtxId(mainId: Long) = + JtxObjectAndExceptions( + main = main.withIntField(JtxContract.JtxICalObject.ID, mainId), + exceptions = exceptions ) \ No newline at end of file From ed9d510e6281c2b316a147789c5dc91c3c86ecc5 Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Thu, 21 May 2026 15:20:28 +0200 Subject: [PATCH 2/3] Fix typo in kdoc --- .../at/bitfire/synctools/storage/jtx/JtxRecurringCollection.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/storage/jtx/JtxRecurringCollection.kt b/lib/src/main/kotlin/at/bitfire/synctools/storage/jtx/JtxRecurringCollection.kt index 6ea4ed096..d85904aed 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/storage/jtx/JtxRecurringCollection.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/storage/jtx/JtxRecurringCollection.kt @@ -288,7 +288,7 @@ class JtxRecurringCollection( * Iterates through all exceptions in [collection] that are marked as deleted. * For every found exception: * - * - the [JtxContract.JtxICalObject.SEQUENCE] field of the main oject is increased by one, + * - the [JtxContract.JtxICalObject.SEQUENCE] field of the main onject is increased by one, * - the main object is marked as dirty (so that it will be synced), * - and the exception is permanently removed (so that it won't appear during sync). * From 89648f27ac92fea7b5cc8fa3ce85b3f8719044cc Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Thu, 21 May 2026 15:31:58 +0200 Subject: [PATCH 3/3] Guard against misuse of exception IDs --- .../storage/jtx/JtxRecurringCollectionTest.kt | 64 +++++++++++++++++++ .../storage/jtx/JtxRecurringCollection.kt | 47 ++++++++++++-- 2 files changed, 106 insertions(+), 5 deletions(-) diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/jtx/JtxRecurringCollectionTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/jtx/JtxRecurringCollectionTest.kt index 343dfa17a..51e589cd7 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/jtx/JtxRecurringCollectionTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/jtx/JtxRecurringCollectionTest.kt @@ -13,6 +13,7 @@ import android.content.Entity import androidx.core.content.contentValuesOf import androidx.test.platform.app.InstrumentationRegistry import at.bitfire.ical4android.TaskProvider +import at.bitfire.synctools.storage.LocalStorageException import at.bitfire.synctools.test.GrantPermissionOrSkipRule import at.bitfire.synctools.test.assertJtxObjectAndExceptionsEqual import at.bitfire.synctools.test.withJtxId @@ -24,6 +25,7 @@ import io.mockk.spyk import org.junit.After import org.junit.AfterClass import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Assert.fail @@ -130,6 +132,68 @@ class JtxRecurringCollectionTest { assertNull(recurringCollection.getById(Long.MAX_VALUE)) } + @Test + fun testGetById_withExceptionId_returnsNull() { + val uid = "testGetById_withExceptionId" + val (mainId, _) = insertRecurring(uid = uid) + + // find the exception row and call getById with its ID + val exceptionId = collection.findJtxObjects( + "${JtxContract.JtxICalObject.UID}=? AND ${JtxContract.JtxICalObject.RECURID} IS NOT NULL AND ${JtxContract.JtxICalObject.SEQUENCE} > 0", + arrayOf(uid) + ).first().entityValues.getAsLong(JtxContract.JtxICalObject.ID) + + assertNull("getById must return null when called with an exception ID", recurringCollection.getById(exceptionId)) + + // the main object must still be accessible + val main = recurringCollection.getById(mainId) + assertEquals(1, main!!.exceptions.size) + } + + @Test + fun testUpdateJtxObjectAndExceptions_withExceptionId_throws() { + val uid = "testUpdateJtxObjectAndExceptions_withExceptionId" + insertRecurring(uid = uid) + + val exceptionId = collection.findJtxObjects( + "${JtxContract.JtxICalObject.UID}=? AND ${JtxContract.JtxICalObject.RECURID} IS NOT NULL AND ${JtxContract.JtxICalObject.SEQUENCE} > 0", + arrayOf(uid) + ).first().entityValues.getAsLong(JtxContract.JtxICalObject.ID) + + try { + recurringCollection.updateJtxObjectAndExceptions(exceptionId, JtxObjectAndExceptions( + main = Entity(contentValuesOf( + JtxContract.JtxICalObject.ICALOBJECT_COLLECTIONID to collection.id, + JtxContract.JtxICalObject.COMPONENT to Component.VTODO.name, + JtxContract.JtxICalObject.UID to uid, + JtxContract.JtxICalObject.SUMMARY to "Should not work" + )), + exceptions = emptyList() + )) + fail("updateJtxObjectAndExceptions must throw when called with an exception ID") + } catch (_: LocalStorageException) { + // expected + } + } + + @Test + fun testDeleteJtxObjectAndExceptions_withExceptionId_leavesMainIntact() { + val uid = "testDeleteJtxObjectAndExceptions_withExceptionId" + val (mainId, _) = insertRecurring(uid = uid) + + val exceptionId = collection.findJtxObjects( + "${JtxContract.JtxICalObject.UID}=? AND ${JtxContract.JtxICalObject.RECURID} IS NOT NULL AND ${JtxContract.JtxICalObject.SEQUENCE} > 0", + arrayOf(uid) + ).first().entityValues.getAsLong(JtxContract.JtxICalObject.ID) + + recurringCollection.deleteJtxObjectAndExceptions(exceptionId) + + // main object and its exception must still exist + val result = recurringCollection.getById(mainId) + assertNotNull("Main object must still exist after delete was called with exception ID", result) + assertEquals(1, result!!.exceptions.size) + } + @Test fun testIterateJtxObjectAndExceptions() { val uid1 = "testIterateJtxObjectAndExceptions1" diff --git a/lib/src/main/kotlin/at/bitfire/synctools/storage/jtx/JtxRecurringCollection.kt b/lib/src/main/kotlin/at/bitfire/synctools/storage/jtx/JtxRecurringCollection.kt index d85904aed..70230b2b1 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/storage/jtx/JtxRecurringCollection.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/storage/jtx/JtxRecurringCollection.kt @@ -88,12 +88,21 @@ class JtxRecurringCollection( * Retrieves a jtx object and its exceptions from the content provider. * Exceptions are found by matching [JtxContract.JtxICalObject.UID] of the main object. * + * Returns `null` if [mainId] refers to an exception row (i.e. [JtxContract.JtxICalObject.RECURID] + * IS NOT NULL), because jtx exceptions are linked by UID and treating an exception as a main + * would produce an invalid [JtxObjectAndExceptions] (the exception would also appear in its own + * exceptions list). + * * @param mainId [JtxContract.JtxICalObject.ID] of the main jtx object * - * @return jtx object and exceptions, or `null` if not found + * @return jtx object and exceptions, or `null` if not found or [mainId] is an exception */ fun getById(mainId: Long): JtxObjectAndExceptions? { val main = collection.getJtxObject(mainId) ?: return null + if (main.entityValues.getAsString(JtxContract.JtxICalObject.RECURID) != null) { + logger.warning("getById called with exception ID $mainId (RECURID IS NOT NULL) – returning null") + return null + } val uid = main.entityValues.getAsString(JtxContract.JtxICalObject.UID) return JtxObjectAndExceptions( main = main, @@ -124,10 +133,12 @@ class JtxRecurringCollection( * Updates a jtx object and all its exceptions. Input data is first cleaned up using [cleanUp]. * Old exceptions are deleted and replaced with the ones provided. * - * @param id ID of the main jtx object row + * @param id ID of the main jtx object row (must have [JtxContract.JtxICalObject.RECURID] IS NULL) * @param objectAndExceptions new jtx object (including exceptions) * * @return main jtx object ID of the updated row (always equal to [id]) + * + * @throws LocalStorageException when [id] refers to an exception row instead of a main object */ fun updateJtxObjectAndExceptions(id: Long, objectAndExceptions: JtxObjectAndExceptions): Long { try { @@ -135,9 +146,18 @@ class JtxRecurringCollection( val cleaned = cleanUp(objectAndExceptions) // get UID of the existing main object to find and delete its old exceptions (because - // they may be invalid for the updated event) - val existingUid = collection.getJtxObjectRow(id, arrayOf(JtxContract.JtxICalObject.UID)) - ?.getAsString(JtxContract.JtxICalObject.UID) + // they may be invalid for the updated event); also enforce that id is a main object + // (RECURID IS NULL) — jtx exceptions are linked by UID, so updating an exception as + // if it were a main would corrupt the data + val existingRow = collection.getJtxObjectRow( + id, + arrayOf(JtxContract.JtxICalObject.UID, JtxContract.JtxICalObject.RECURID), + where = "${JtxContract.JtxICalObject.RECURID} IS NULL" + ) + if (existingRow == null) { + throw LocalStorageException("updateJtxObjectAndExceptions called with ID $id which does not exist or is an exception (RECURID IS NOT NULL)") + } + val existingUid = existingRow.getAsString(JtxContract.JtxICalObject.UID) val batch = JtxBatchOperation(collection.client) @@ -169,10 +189,27 @@ class JtxRecurringCollection( /** * Deletes a jtx object and all its exceptions. * + * Does nothing if [id] refers to an exception row (i.e. [JtxContract.JtxICalObject.RECURID] + * IS NOT NULL), because deleting only the exception would leave the actual main and the + * remaining exceptions dangling. + * * @param id ID of the main jtx object */ fun deleteJtxObjectAndExceptions(id: Long) { try { + // Guard: verify the row exists and is a main object (RECURID IS NULL). + // Deleting an exception row here would leave its main and sibling exceptions dangling, + // because the jtx Board provider's removeOrphans() only runs when the main is removed. + val row = collection.getJtxObjectRow( + id, + arrayOf(JtxContract.JtxICalObject.ID), + where = "${JtxContract.JtxICalObject.RECURID} IS NULL" + ) + if (row == null) { + logger.warning("deleteJtxObjectAndExceptions called with ID $id which does not exist or is an exception (RECURID IS NOT NULL) – ignoring") + return + } + val batch = JtxBatchOperation(collection.client) // delete main object; the jtx Board provider's removeOrphans() will clean up